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.
- texmodel/__init__.py +6 -0
- texmodel/_internal/__init__.py +1 -0
- texmodel/_internal/render.py +323 -0
- texmodel/_internal/scan.py +23 -0
- texmodel/body.py +364 -0
- texmodel/bundle.py +69 -0
- texmodel/cli.py +211 -0
- texmodel/components.py +757 -0
- texmodel/config.py +32 -0
- texmodel/drift.py +55 -0
- texmodel/errors.py +38 -0
- texmodel/package.py +482 -0
- texmodel/plan.py +92 -0
- texmodel/pointer.py +70 -0
- texmodel/preamble.py +110 -0
- texmodel/py.typed +0 -0
- texmodel/spec.py +235 -0
- texmodel/std/__init__.py +37 -0
- texmodel/std/adjustbox.py +19 -0
- texmodel/std/anyfontsize.py +7 -0
- texmodel/std/calc.py +7 -0
- texmodel/std/enumitem.py +18 -0
- texmodel/std/eso_pic.py +49 -0
- texmodel/std/etoolbox.py +127 -0
- texmodel/std/fontspec.py +36 -0
- texmodel/std/geometry.py +17 -0
- texmodel/std/graphicx.py +26 -0
- texmodel/std/hyphenat.py +7 -0
- texmodel/std/latex.py +516 -0
- texmodel/std/microtype.py +27 -0
- texmodel/std/pdfrender.py +17 -0
- texmodel/std/tcolorbox.py +43 -0
- texmodel/std/tikz.py +36 -0
- texmodel/std/xcolor.py +47 -0
- texmodel/testing.py +297 -0
- texmodel/types.py +264 -0
- texmodel-0.2.0.dist-info/METADATA +525 -0
- texmodel-0.2.0.dist-info/RECORD +61 -0
- texmodel-0.2.0.dist-info/WHEEL +4 -0
- texmodel-0.2.0.dist-info/entry_points.txt +9 -0
- texmodel-0.2.0.dist-info/licenses/LICENSE +21 -0
- texmodel_build/__init__.py +10 -0
- texmodel_build/_internal/__init__.py +1 -0
- texmodel_build/_internal/converge.py +19 -0
- texmodel_build/_internal/env.py +42 -0
- texmodel_build/_internal/logparse.py +170 -0
- texmodel_build/_internal/proofpdf.py +52 -0
- texmodel_build/_internal/spawn.py +36 -0
- texmodel_build/_internal/styscan.py +37 -0
- texmodel_build/cli.py +349 -0
- texmodel_build/compiler.py +341 -0
- texmodel_build/config.py +58 -0
- texmodel_build/diagnostics.py +225 -0
- texmodel_build/engines.py +35 -0
- texmodel_build/errors.py +17 -0
- texmodel_build/py.typed +0 -0
- texmodel_build/result.py +65 -0
- texmodel_build/roots.py +41 -0
- texmodel_build/targets.py +43 -0
- texmodel_build/testkit.py +59 -0
- 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"]
|
texmodel/std/__init__.py
ADDED
|
@@ -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)
|
texmodel/std/enumitem.py
ADDED
|
@@ -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
|
texmodel/std/eso_pic.py
ADDED
|
@@ -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
|