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/plan.py ADDED
@@ -0,0 +1,92 @@
1
+ """A document's plan (B2, D2): every output line with the path of what wrote it, so an edit to the file or a line of a
2
+ compile log names the component, not a line number.
3
+
4
+ `Package.plan(...)` returns it; `Plan.to_json()` is stored beside a committed document as its baseline, and
5
+ `Plan.edits(text)` compares a file with it by line, so a hand edit is found by path even when the model has moved on.
6
+ """
7
+
8
+ import dataclasses
9
+ import difflib
10
+ import enum
11
+ import json
12
+ from collections.abc import Iterator
13
+
14
+ from .components import Environment, Node
15
+
16
+
17
+ @dataclasses.dataclass(frozen=True)
18
+ class Line:
19
+ path: str # "Card[1]/CardBack[1]/Name[0]", "preamble.after_package[2]", "header", "document"
20
+ tex: str
21
+
22
+
23
+ @enum.unique
24
+ class EditKind(enum.Enum):
25
+ ADDED = "added" # in the file, not in the plan
26
+ REMOVED = "removed" # in the plan, not in the file
27
+ CHANGED = "changed"
28
+
29
+
30
+ @dataclasses.dataclass(frozen=True)
31
+ class Edit:
32
+ """One difference. `kind` says which side lacks the line: a blank line has no text to tell by."""
33
+
34
+ path: str
35
+ expected: str
36
+ found: str
37
+ kind: EditKind
38
+
39
+
40
+ def node_lines(node: Node, path: str) -> Iterator[Line]:
41
+ """The lines a node renders; children are indexed within their parent, as `validate()` paths are."""
42
+ rendered = node.render().splitlines()
43
+ if not isinstance(node, Environment):
44
+ yield from (Line(path, tex) for tex in rendered)
45
+ return
46
+ yield Line(path, rendered[0])
47
+ for i, child in enumerate(node.content):
48
+ yield from node_lines(child, f"{path}/{type(child).__name__}[{i}]")
49
+ yield Line(path, rendered[-1])
50
+
51
+
52
+ def _compared(line: str, ignore_comments: bool) -> str:
53
+ """A comment line compares equal to any other comment line when comments are ignored."""
54
+ return "%" if ignore_comments and line.lstrip().startswith("%") else line
55
+
56
+
57
+ @dataclasses.dataclass(frozen=True)
58
+ class Plan:
59
+ lines: tuple[Line, ...]
60
+
61
+ @property
62
+ def text(self) -> str:
63
+ return "\n".join(line.tex for line in self.lines) + "\n"
64
+
65
+ def path_of(self, number: int) -> str:
66
+ """The path that wrote line `number` (1-based, as TeX counts), e.g. from a log's `input line 39`."""
67
+ return self.lines[number - 1].path
68
+
69
+ def edits(self, text: str, *, ignore_comments: bool = False) -> list[Edit]:
70
+ """What `text` changed against this plan; each difference carries the path of the line it replaced. With
71
+ `ignore_comments`, a line that is a comment (starts with `%`) in both is not compared, so a changed audit block
72
+ is no edit; a comment turned into text, or text into a comment, still is."""
73
+ found = text.splitlines()
74
+ out: list[Edit] = []
75
+ a = [_compared(x.tex, ignore_comments) for x in self.lines]
76
+ b = [_compared(x, ignore_comments) for x in found]
77
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b).get_opcodes():
78
+ if tag == "equal":
79
+ continue
80
+ for k in range(max(i2 - i1, j2 - j1)):
81
+ was = self.lines[i1 + k] if i1 + k < i2 else None
82
+ path = was.path if was else self.lines[i2 - 1].path # an added line belongs to the line before it
83
+ kind = EditKind.CHANGED if was and j1 + k < j2 else EditKind.REMOVED if was else EditKind.ADDED
84
+ out.append(Edit(path, was.tex if was else "", found[j1 + k] if j1 + k < j2 else "", kind))
85
+ return out
86
+
87
+ def to_json(self) -> str:
88
+ return json.dumps([[x.path, x.tex] for x in self.lines], ensure_ascii=False, indent=0) + "\n"
89
+
90
+ @classmethod
91
+ def from_json(cls, text: str) -> "Plan":
92
+ return cls(tuple(Line(path, tex) for path, tex in json.loads(text)))
texmodel/pointer.py ADDED
@@ -0,0 +1,70 @@
1
+ """Find what a project names with a pointer (`module:object`, `module:getter()`): its `Package`, a document or a `Plan`.
2
+ Public, so the build harness reads the same pointers."""
3
+
4
+ import importlib
5
+ import pathlib
6
+ import re
7
+ import sys
8
+ from typing import Any
9
+
10
+ from .errors import DeclarationError
11
+ from .package import Package
12
+ from .plan import Plan
13
+
14
+ _POINTER = re.compile(
15
+ r"(?P<module>[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*):(?P<attr>[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)(?P<call>\(\))?"
16
+ )
17
+
18
+
19
+ def _load(spec: str, root: pathlib.Path, what: str) -> Any: # noqa: ANN401 # whatever the project put there
20
+ """Resolve a pointer: `pkg.module:object`, `pkg.module:namespace.object` or `pkg.module:get_object()`, looking in
21
+ `root` and `root/src` first. A trailing `()` calls the object with no arguments and uses what it returns."""
22
+ if not (m := _POINTER.fullmatch(spec)):
23
+ raise DeclarationError(
24
+ f"{spec!r} must be 'module:object', 'module:namespace.object' or 'module:getter()'",
25
+ code="TM-D020",
26
+ hint="the module part is dotted, the object part is a name or a dotted path to one",
27
+ )
28
+ for path in (root, root / "src"):
29
+ if str(path) not in sys.path:
30
+ sys.path.insert(0, str(path))
31
+ try:
32
+ obj: Any = importlib.import_module(m["module"])
33
+ for name in m["attr"].split("."):
34
+ obj = getattr(obj, name)
35
+ return obj() if m["call"] else obj
36
+ except (ImportError, AttributeError, TypeError) as exc:
37
+ raise DeclarationError(f"cannot load {spec!r}: {exc}", code="TM-D021", hint=f"check {what}") from exc
38
+
39
+
40
+ def load_package(spec: str, root: pathlib.Path) -> Package:
41
+ """Require the loaded object to be a `Package`."""
42
+ package = _load(spec, root, "[tool.texmodel] package")
43
+ if not isinstance(package, Package):
44
+ raise DeclarationError(f"{spec!r} is a {type(package).__name__}, not a Package", code="TM-D022")
45
+ return package
46
+
47
+
48
+ def load_document(spec: str, root: pathlib.Path) -> str | Plan:
49
+ """The document: a `str` (`Package.document()`) or a `Plan` (`Package.plan()`), or a function returning one."""
50
+ obj = _load(spec, root, "the module:attribute you passed")
51
+ text = obj() if callable(obj) else obj
52
+ if not isinstance(text, str | Plan):
53
+ raise DeclarationError(
54
+ f"{spec!r} is a {type(text).__name__}, not a document",
55
+ code="TM-D024",
56
+ hint="point at a str or Plan from Package.document() / plan(), or a function returning one",
57
+ )
58
+ return text
59
+
60
+
61
+ def load_plan(spec: str, root: pathlib.Path) -> Plan:
62
+ """The `Plan` a pointer names (`Package.plan()`), for the line-to-component map of a diagnostic (B2, D2)."""
63
+ found = load_document(spec, root)
64
+ if not isinstance(found, Plan):
65
+ raise DeclarationError(
66
+ f"{spec!r} is a str, not a Plan",
67
+ code="TM-D025",
68
+ hint="point at Package.plan(...) (or a function returning one): a str has no line-to-component map",
69
+ )
70
+ return found
texmodel/preamble.py ADDED
@@ -0,0 +1,110 @@
1
+ """Typed preamble entries (§21a 10, G3): each renders to one `Raw` and knows which `PreambleSlots` slots it may sit in.
2
+
3
+ ```python
4
+ PreambleSlots(
5
+ before_class=(RequireMetadata(lang="en-GB", pdfstandard="A-2b"),),
6
+ before_package=(InputPath("res/"), PassOption("xcolor", "table")),
7
+ after_package=(GraphicsPath("images/"), PdfInfo(Title="Rex")),
8
+ )
9
+ ```
10
+ """
11
+
12
+ import dataclasses
13
+ import re
14
+ from typing import ClassVar
15
+
16
+ from .errors import DeclarationError
17
+ from .types import Raw, pdf_escape
18
+
19
+ BEFORE_CLASS, BEFORE_PACKAGE, AFTER_PACKAGE = "before_class", "before_package", "after_package"
20
+
21
+
22
+ @dataclasses.dataclass(frozen=True)
23
+ class PassOption:
24
+ """`\\PassOptionsToPackage{option}{package}`: before the package loads."""
25
+
26
+ package: str
27
+ option: str
28
+ slots: ClassVar[tuple[str, ...]] = (BEFORE_CLASS, BEFORE_PACKAGE)
29
+
30
+ def raw(self) -> Raw:
31
+ return Raw(f"\\PassOptionsToPackage{{{self.option}}}{{{self.package}}}")
32
+
33
+
34
+ @dataclasses.dataclass(frozen=True, init=False)
35
+ class InputPath:
36
+ """Where `\\input` and `\\usepackage` look: the package's own files must be found when it loads."""
37
+
38
+ dirs: tuple[str, ...]
39
+ slots: ClassVar[tuple[str, ...]] = (BEFORE_PACKAGE,)
40
+
41
+ def __init__(self, *dirs: str) -> None:
42
+ object.__setattr__(self, "dirs", dirs)
43
+
44
+ def raw(self) -> Raw:
45
+ return Raw(f"\\makeatletter\\def\\input@path{{{''.join(f'{{{d}}}' for d in self.dirs)}}}\\makeatother")
46
+
47
+
48
+ @dataclasses.dataclass(frozen=True, init=False)
49
+ class GraphicsPath:
50
+ """`\\graphicspath`: it needs graphicx, which the package loads. The current directory is the empty first group."""
51
+
52
+ dirs: tuple[str, ...]
53
+ include_cwd: bool
54
+ slots: ClassVar[tuple[str, ...]] = (AFTER_PACKAGE,)
55
+
56
+ def __init__(self, *dirs: str, include_cwd: bool = True) -> None:
57
+ object.__setattr__(self, "dirs", dirs)
58
+ object.__setattr__(self, "include_cwd", include_cwd)
59
+
60
+ def raw(self) -> Raw:
61
+ groups = ("{}" if self.include_cwd else "") + "".join(f"{{{d}}}" for d in self.dirs)
62
+ return Raw(f"\\graphicspath{{{groups}}}")
63
+
64
+
65
+ @dataclasses.dataclass(frozen=True, init=False)
66
+ class RequireMetadata:
67
+ """`\\DocumentMetadata{key=value, ...}`: before `\\documentclass`. A value with `,` `=` or a space is braced."""
68
+
69
+ entries: tuple[tuple[str, str], ...]
70
+ slots: ClassVar[tuple[str, ...]] = (BEFORE_CLASS,)
71
+
72
+ def __init__(self, **entries: str) -> None:
73
+ object.__setattr__(self, "entries", tuple(entries.items()))
74
+
75
+ def raw(self) -> Raw:
76
+ def value(v: str) -> str:
77
+ return f"{{{v}}}" if any(c in v for c in ",= ") else v
78
+
79
+ return Raw(f"\\DocumentMetadata{{{', '.join(f'{k}={value(v)}' for k, v in self.entries)}}}")
80
+
81
+
82
+ @dataclasses.dataclass(frozen=True, init=False)
83
+ class PdfInfo:
84
+ """`PdfInfo(Title="Rex")`: entries of the PDF Info dictionary. Each value is PDF-escaped and written through
85
+ `pdfmanagement_add:nnn`, so any text reaches the PDF as it is (§21a 9); the route needs `\\DocumentMetadata`, so a
86
+ document with one has a `RequireMetadata` in `before_class` (`TM-C009`). A key is letters only (`TM-M035`)."""
87
+
88
+ entries: tuple[tuple[str, str], ...]
89
+ slots: ClassVar[tuple[str, ...]] = (BEFORE_PACKAGE, AFTER_PACKAGE)
90
+
91
+ def __init__(self, **entries: str) -> None:
92
+ if bad := [k for k in entries if not re.fullmatch(r"[A-Za-z]+", k)]:
93
+ raise DeclarationError(f"PdfInfo key {bad[0]!r}: letters only", code="TM-M035", path="PdfInfo")
94
+ object.__setattr__(self, "entries", tuple(entries.items()))
95
+
96
+ def raw(self) -> Raw:
97
+ return Raw(
98
+ "\n".join(
99
+ f"\\csname pdfmanagement_add:nnn\\endcsname{{Info}}{{{k}}}{{(\\detokenize{{{pdf_escape(v)}}})}}"
100
+ for k, v in self.entries
101
+ )
102
+ )
103
+
104
+
105
+ type Entry = PassOption | InputPath | GraphicsPath | RequireMetadata | PdfInfo
106
+
107
+
108
+ def tex_of(item: Raw | Entry) -> Raw:
109
+ """The `Raw` a slot item stands for."""
110
+ return item if isinstance(item, Raw) else item.raw()
texmodel/py.typed ADDED
File without changes
texmodel/spec.py ADDED
@@ -0,0 +1,235 @@
1
+ """A Doc from plain data and back (D2, S): `from_dict`, `to_dict` and `json_schema`, built on pydantic.
2
+
3
+ Data is what TOML or JSON gives: a component is a dict with its fields, an environment also has `content`, a list of
4
+ children, and a child names its component with `"type"`:
5
+
6
+ ```python
7
+ from_dict(Card, {"content": [
8
+ {"type": "CardFront", "content": [{"type": "CardImage", "path": "a.jpg"}]},
9
+ {"type": "CardBack", "content": [{"type": "Name", "text": "Fox"}, {"type": "LeadFact", "text": "..."}]},
10
+ ]})
11
+ ```
12
+
13
+ Fields are coerced as pydantic does by default (a `Raw` field takes a string). Everything wrong is reported at once,
14
+ as an `ExceptionGroup` of `ValidationError`, each with the path `validate()` would give it; the grammar (counts and
15
+ order, M4) is checked by `validate()` on the result, so a document that loads is not yet a valid one.
16
+ """
17
+
18
+ import typing
19
+ from collections.abc import Callable, Iterable, Mapping, Sequence
20
+ from typing import Annotated, Any
21
+
22
+ import pydantic
23
+ from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode
24
+
25
+ from .body import Comment
26
+ from .components import Command, Environment, Node, Passthrough, Value
27
+ from .errors import ValidationError, Violation
28
+ from .package import Package
29
+ from .types import Choice, Raw
30
+
31
+ _MODELS: dict[tuple[type[Environment], tuple[type[Comment], ...]], type[pydantic.BaseModel]] = {}
32
+ _COMMENT_MODELS: dict[type[Comment], type[pydantic.BaseModel]] = {}
33
+
34
+
35
+ def _tag(value: object) -> object:
36
+ return value.get("type") if isinstance(value, Mapping) else type(value).__name__ # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
37
+
38
+
39
+ def _renamer(cls: type[Command] | type[Environment] | type[Passthrough]) -> Callable[[object], object]:
40
+ """A before-validator that moves a field's old key (`field(renamed_from=)`) to its name; both given is an error."""
41
+ old = {a.renamed_from: a.name for a in cls.__tm_args__ if a.renamed_from}
42
+
43
+ def rename(data: object) -> object:
44
+ if not old or not isinstance(data, Mapping):
45
+ return data
46
+ out = dict(typing.cast("Mapping[str, object]", data))
47
+ for was, name in old.items():
48
+ if was in out:
49
+ if name in out:
50
+ raise ValueError(f"{name!r} and its old name {was!r} are both given")
51
+ out[name] = out.pop(was)
52
+ return out
53
+
54
+ return rename
55
+
56
+
57
+ def _same(data: object) -> object:
58
+ return data
59
+
60
+
61
+ def _comment_model(kind: type[Comment]) -> type[pydantic.BaseModel]:
62
+ if kind not in _COMMENT_MODELS:
63
+ fields: dict[str, Any] = dict.fromkeys(kind.__tm_fields__, (str, ...))
64
+ _COMMENT_MODELS[kind] = pydantic.create_model(kind.__name__, __doc__=kind.__doc__, **fields)
65
+ _COMMENT_MODELS[kind].__tm_cls__ = kind # pyright: ignore[reportAttributeAccessIssue]
66
+ return _COMMENT_MODELS[kind]
67
+
68
+
69
+ def _model(cls: type[Environment], comments: tuple[type[Comment], ...]) -> type[pydantic.BaseModel]:
70
+ """The pydantic model of an environment: `content` limited to its grammar's components and to `Comment` and
71
+ `comments` (a comment is not in a grammar), and its arguments."""
72
+ if (cls, comments) not in _MODELS:
73
+ members: list[tuple[Any, Callable[[object], object]]] = []
74
+ for t in cls.__tm_grammar__:
75
+ kind = typing.cast("type[Command] | type[Environment] | type[Passthrough]", t.kind)
76
+ members.append((_model(kind, comments) if issubclass(kind, Environment) else kind, _renamer(kind)))
77
+ members += [(_comment_model(c), _same) for c in (Comment, *comments)]
78
+ tagged = tuple(Annotated[m, pydantic.BeforeValidator(r), pydantic.Tag(m.__name__)] for m, r in members)
79
+ fields: dict[str, Any] = {"content": (list[Annotated[typing.Union[tagged], pydantic.Discriminator(_tag)]], ...)} # noqa: UP007
80
+ for a in cls.__tm_args__:
81
+ kind = a.choice or (Raw if a.raw else str)
82
+ fields[a.name] = (kind | None, None) if a.optional else (kind, ...)
83
+ _MODELS[cls, comments] = pydantic.create_model(cls.__name__, **fields)
84
+ _MODELS[cls, comments].__tm_cls__ = cls # pyright: ignore[reportAttributeAccessIssue]
85
+ return _MODELS[cls, comments]
86
+
87
+
88
+ def _node(value: Any) -> Node: # noqa: ANN401 # an instance of a model built at run time, or a Command
89
+ if isinstance(value, Command | Passthrough):
90
+ return value
91
+ cls: type[Environment] | type[Comment] = value.__tm_cls__
92
+ if issubclass(cls, Comment):
93
+ return cls(**{f: getattr(value, f) for f in cls.__tm_fields__})
94
+ args = {a.name: getattr(value, a.name) for a in cls.__tm_args__}
95
+ return cls(*(_node(c) for c in value.content), **args)
96
+
97
+
98
+ def _path(root: str, loc: Sequence[str | int]) -> str:
99
+ """`('content', 1, 'LeadFact', 'text')` under `Card` is `Card/LeadFact[1].text`, as `validate()` writes it."""
100
+ path, index = root, None
101
+ for part in loc:
102
+ if part == "content":
103
+ continue
104
+ if isinstance(part, int):
105
+ index = part
106
+ elif index is not None:
107
+ path, index = f"{path}/{part}[{index}]", None
108
+ else:
109
+ path = f"{path}.{part}"
110
+ return path if index is None else f"{path}/content[{index}]" # a child no component of the grammar fits
111
+
112
+
113
+ def from_dict(
114
+ cls: type[Command] | type[Environment], data: Mapping[str, object], *, comments: Sequence[type[Comment]] = ()
115
+ ) -> Node:
116
+ """The node `data` describes, as an instance of `cls`; raises an `ExceptionGroup` of `ValidationError` (E2).
117
+ A child may also be a `Comment` (`"type": "Comment"`, `"text"`) or an instance of one of `comments`."""
118
+ try:
119
+ data = typing.cast("Mapping[str, object]", _renamer(cls)(data))
120
+ except ValueError as exc: # a renamed key given with its new name too
121
+ raise ExceptionGroup(f"invalid {cls.__name__}", [Violation("TM-C020", cls.__name__, str(exc)).error()]) from exc
122
+ try:
123
+ if issubclass(cls, Command):
124
+ return pydantic.TypeAdapter(cls).validate_python(data) # pyright: ignore[reportUnknownVariableType]
125
+ return _node(_model(cls, tuple(comments)).model_validate(data))
126
+ except pydantic.ValidationError as exc:
127
+ raise ExceptionGroup(
128
+ f"invalid {cls.__name__}",
129
+ [
130
+ Violation("TM-C020", _path(cls.__name__, e["loc"]), e["msg"]).error()
131
+ for e in exc.errors(include_url=False)
132
+ ],
133
+ ) from exc
134
+
135
+
136
+ def to_dict(node: Node) -> dict[str, object]:
137
+ """The data of `node`, in the shape `from_dict` reads; an optional field that is `None` is left out."""
138
+ out: dict[str, object] = {"type": type(node).__name__}
139
+ if isinstance(node, Comment):
140
+ return out | {f: getattr(node, f) for f in node.__tm_fields__}
141
+ for a in type(node).__tm_args__:
142
+ value = getattr(node, a.name)
143
+ if value is not None:
144
+ out[a.name] = value.tex if isinstance(value, Raw) else value.name if isinstance(value, Choice) else value
145
+ if isinstance(node, Environment):
146
+ out["content"] = [to_dict(child) for child in node.content]
147
+ return out
148
+
149
+
150
+ def _reachable(classes: Iterable[type]) -> dict[str, type]:
151
+ """The given classes and every component their grammars name, by class name."""
152
+ found: dict[str, type] = {}
153
+ todo = list(classes)
154
+ while todo:
155
+ cls = todo.pop()
156
+ if cls.__name__ not in found:
157
+ found[cls.__name__] = cls
158
+ todo += [t.kind for t in getattr(cls, "__tm_grammar__", ())]
159
+ return found
160
+
161
+
162
+ def _requires_type(node: object) -> None:
163
+ """A child is one of several components, told apart by `"type"`: each branch of such a union requires it, so a
164
+ child's data matches exactly one branch."""
165
+ if isinstance(node, dict):
166
+ branches = typing.cast("dict[str, Any]", node).get("oneOf")
167
+ if isinstance(branches, list) and all("$ref" in b for b in typing.cast("list[dict[str, Any]]", branches)):
168
+ for branch in typing.cast("list[dict[str, Any]]", branches):
169
+ branch["required"] = ["type"]
170
+ for value in typing.cast("dict[str, Any]", node).values():
171
+ _requires_type(value)
172
+ elif isinstance(node, list):
173
+ for value in typing.cast("list[object]", node):
174
+ _requires_type(value)
175
+
176
+
177
+ def _annotate(schema: dict[str, Any], classes: Iterable[type], comments: Iterable[type] = ()) -> None:
178
+ """Name every component by its `"type"` constant, and record `x-renamed-from` on each renamed field (§21a 15)."""
179
+ named = _reachable(classes)
180
+ kinds = {*named, *(c.__name__ for c in (Comment, *comments))}
181
+ for node in (schema, *schema.get("$defs", {}).values()):
182
+ title = str(node.get("title"))
183
+ if title in kinds:
184
+ node.setdefault("properties", {})["type"] = {"const": title}
185
+ for a in getattr(named.get(title), "__tm_args__", ()):
186
+ if a.renamed_from and a.name in node.get("properties", {}):
187
+ node["properties"][a.name]["x-renamed-from"] = a.renamed_from
188
+ _requires_type(schema)
189
+
190
+
191
+ _VALUE_TYPES = {"Length": "string", "Colour": "string", "Bool": "boolean", "Text": "string", "Raw": "string"}
192
+
193
+
194
+ def _package_schema(package: Package, comments: tuple[type[Comment], ...]) -> dict[str, Any]:
195
+ """One schema for a package: a `$defs` entry per command and environment, and `Value.<Name>` per value."""
196
+ nodes = [c for c in package.registered if issubclass(c, Command | Environment)]
197
+ inputs: list[tuple[str, JsonSchemaMode, Any]] = [
198
+ (
199
+ c.__name__,
200
+ "validation",
201
+ pydantic.TypeAdapter(c if issubclass(c, Command) else _model(c, comments)).core_schema,
202
+ )
203
+ for c in nodes
204
+ ]
205
+ _, defs = GenerateJsonSchema(ref_template="#/$defs/{model}").generate_definitions(inputs)
206
+ schema: dict[str, Any] = {"$defs": defs}
207
+ schema |= {"$schema": "https://json-schema.org/draft/2020-12/schema", "title": package.name}
208
+ for cls in package.registered:
209
+ if issubclass(cls, Value):
210
+ schema["$defs"][f"Value.{cls.__name__}"] = {
211
+ "title": cls.__name__,
212
+ "description": cls.__tm_doc__,
213
+ "type": _VALUE_TYPES[cls.__tm_kind__],
214
+ }
215
+ _annotate(schema, nodes, comments)
216
+ return schema
217
+
218
+
219
+ def json_schema(
220
+ target: type[Command] | type[Environment] | Package, *, comments: Sequence[type[Comment]] = ()
221
+ ) -> dict[str, Any]:
222
+ """JSON Schema of the data `from_dict(cls, ..., comments=)` accepts; docstrings are descriptions. Given a `Package`,
223
+ one schema with a `$defs` entry for every registered component and value; a renamed field has `x-renamed-from`."""
224
+ if isinstance(target, Package):
225
+ return _package_schema(target, tuple(comments))
226
+ schema = (
227
+ pydantic.TypeAdapter(target).json_schema()
228
+ if issubclass(target, Command)
229
+ else _model(target, tuple(comments)).model_json_schema()
230
+ )
231
+ _annotate(schema, [target], comments)
232
+ return schema
233
+
234
+
235
+ __all__ = ["ValidationError", "from_dict", "json_schema", "to_dict"]
@@ -0,0 +1,37 @@
1
+ r"""Headers for the standard TeX libraries a deck loads (one module per package, adopted: the interface only).
2
+
3
+ Written for TeX Live `TEXLIVE`; `tests/test_std` compiles a call of every command against the reference image.
4
+ A header covers what fits the model (required `{}` arguments and one leading `[]`); a library's own syntax
5
+ (TikZ paths, key-value declarations) is written as `Passthrough` or `Raw`.
6
+
7
+ ```python
8
+ from texmodel.std.graphicx import IncludeGraphics
9
+
10
+ class Photo(Command):
11
+ '''A picture.'''
12
+ path: Text
13
+
14
+ def body(self):
15
+ yield IncludeGraphics.call(Hole("path"), options=Raw("width=\\linewidth")) # \RequirePackage{graphicx}
16
+ ```
17
+ """
18
+
19
+ TEXLIVE = "2025"
20
+ MODULES = (
21
+ "latex",
22
+ "graphicx",
23
+ "xcolor",
24
+ "geometry",
25
+ "etoolbox",
26
+ "eso_pic",
27
+ "microtype",
28
+ "hyphenat",
29
+ "anyfontsize",
30
+ "calc",
31
+ "pdfrender",
32
+ "enumitem",
33
+ "adjustbox",
34
+ "tikz",
35
+ "tcolorbox",
36
+ "fontspec",
37
+ )
@@ -0,0 +1,19 @@
1
+ r"""adjustbox: Boxes scaled, clipped and aligned by keys.
2
+
3
+ A header (the package is adopted): it declares the interface, `texmodel` writes calls and never the package."""
4
+
5
+ from texmodel.components import Command
6
+ from texmodel.package import Package
7
+ from texmodel.types import Raw
8
+
9
+ package = Package("adjustbox", prefix="", adopted=True)
10
+
11
+
12
+ @package.command
13
+ class AdjustBox(Command):
14
+ r"""Adjust some content: `\adjustbox{max width=\linewidth}{content}`. (The environment form,
15
+ `\begin{adjustbox}{...}`, is the same macro; declare only one of the two.)"""
16
+
17
+ cs = "adjustbox"
18
+ options: Raw
19
+ content: Raw
@@ -0,0 +1,7 @@
1
+ r"""anyfontsize: Any font size. Loaded with `Package.require`; it declares no commands.
2
+
3
+ A header (the package is adopted): it declares the interface, `texmodel` writes calls and never the package."""
4
+
5
+ from texmodel.package import Package
6
+
7
+ package = Package("anyfontsize", prefix="", adopted=True)
texmodel/std/calc.py ADDED
@@ -0,0 +1,7 @@
1
+ r"""calc: Arithmetic in lengths and counters. Loaded with `Package.require`; it declares no commands.
2
+
3
+ A header (the package is adopted): it declares the interface, `texmodel` writes calls and never the package."""
4
+
5
+ from texmodel.package import Package
6
+
7
+ package = Package("calc", prefix="", adopted=True)
@@ -0,0 +1,18 @@
1
+ r"""enumitem: List layout.
2
+
3
+ A header (the package is adopted): it declares the interface, `texmodel` writes calls and never the package."""
4
+
5
+ from texmodel.components import Command
6
+ from texmodel.package import Package
7
+ from texmodel.types import Raw
8
+
9
+ package = Package("enumitem", prefix="", adopted=True)
10
+
11
+
12
+ @package.command
13
+ class SetList(Command):
14
+ r"""Change list settings: `\setlist[itemize]{noitemsep}`."""
15
+
16
+ cs = "setlist"
17
+ options: Raw
18
+ lists: Raw | None = None
@@ -0,0 +1,49 @@
1
+ r"""eso-pic: Material placed on every page, in absolute positions.
2
+
3
+ A header (the package is adopted): it declares the interface, `texmodel` writes calls and never the package."""
4
+
5
+ from texmodel.components import Command
6
+ from texmodel.package import Package
7
+ from texmodel.types import Raw
8
+
9
+ package = Package("eso-pic", prefix="", adopted=True)
10
+
11
+
12
+ @package.command
13
+ class AddToShipoutPictureBG(Command):
14
+ r"""Add a picture behind the page content."""
15
+
16
+ cs = "AddToShipoutPictureBG"
17
+ code: Raw
18
+
19
+
20
+ @package.command
21
+ class AddToShipoutPictureFG(Command):
22
+ r"""Add a picture over the page content."""
23
+
24
+ cs = "AddToShipoutPictureFG"
25
+ code: Raw
26
+
27
+
28
+ @package.command
29
+ class AtPageLowerLeft(Command):
30
+ r"""Put material at the page's lower left corner."""
31
+
32
+ cs = "AtPageLowerLeft"
33
+ code: Raw
34
+
35
+
36
+ @package.command
37
+ class AtPageUpperLeft(Command):
38
+ r"""Put material at the page's upper left corner."""
39
+
40
+ cs = "AtPageUpperLeft"
41
+ code: Raw
42
+
43
+
44
+ @package.command
45
+ class AtPageCenter(Command):
46
+ r"""Put material at the page's centre."""
47
+
48
+ cs = "AtPageCenter"
49
+ code: Raw