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/body.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Body pieces (M8): what a component's `body` / `begin` / `end` may give, and how line ends are written.
|
|
2
|
+
|
|
3
|
+
A body is a string, a `TemplateRef` (a file next to the module), a `TexRaw` (a string with a whitespace policy), or a
|
|
4
|
+
sequence, iterator or generator of those. Each piece has its own `Whitespace`; one left as `None` takes the package's
|
|
5
|
+
default (`Package(whitespace=)`, itself `KEEP` unless told otherwise).
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
def body(self):
|
|
9
|
+
yield TexRaw(r'''
|
|
10
|
+
\\par\\noindent
|
|
11
|
+
{\\color{ac-ink}#text}
|
|
12
|
+
''', whitespace=Whitespace.TRIM) # written as \\par\\noindent% / {\\color{ac-ink}#text}%
|
|
13
|
+
yield r"\\par" # the package's default policy
|
|
14
|
+
```
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import dataclasses
|
|
18
|
+
import enum
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import textwrap
|
|
22
|
+
from collections.abc import Callable, Generator, Iterator, Sequence
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import ClassVar, dataclass_transform
|
|
25
|
+
|
|
26
|
+
from .errors import DeclarationError, Violation
|
|
27
|
+
from .types import Raw, escape
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@enum.unique
|
|
31
|
+
class Whitespace(enum.Enum):
|
|
32
|
+
"""How the line ends of a body piece are written (M8). A newline in a macro body is a space token in TeX."""
|
|
33
|
+
|
|
34
|
+
KEEP = "keep" # as given: the author places any `%`
|
|
35
|
+
TRIM = "trim" # a `%` is added to each line, so line ends make no space; blank lines still end a paragraph
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@enum.unique
|
|
39
|
+
class Syntax(enum.Enum):
|
|
40
|
+
"""The category codes TeX reads a body with (§21a 12). A body has one syntax; the `.sty` switches on `EXPL3` around
|
|
41
|
+
the definition (`\\ExplSyntaxOn` / `\\ExplSyntaxOff`), where a space is ignored and `~` is the space."""
|
|
42
|
+
|
|
43
|
+
LATEX2E = "latex2e"
|
|
44
|
+
EXPL3 = "expl3"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclasses.dataclass(frozen=True)
|
|
48
|
+
class TexRaw:
|
|
49
|
+
"""TeX for a body, with `#name` holes: dedented and without leading or trailing blank lines. `whitespace` of `None`
|
|
50
|
+
is the package's default; it is ignored in `Syntax.EXPL3`, where line ends make no space."""
|
|
51
|
+
|
|
52
|
+
body: str
|
|
53
|
+
whitespace: Whitespace | None = None
|
|
54
|
+
syntax: Syntax = Syntax.LATEX2E
|
|
55
|
+
|
|
56
|
+
def __post_init__(self) -> None:
|
|
57
|
+
object.__setattr__(self, "body", textwrap.dedent(self.body).strip("\n"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclasses.dataclass(frozen=True, init=False)
|
|
61
|
+
class TemplateRef:
|
|
62
|
+
"""A body kept in a TeX file: `TemplateRef("fact.tex")`. Relative paths are relative to the module that defines the
|
|
63
|
+
component, so the reference works wherever the package is installed; there is no lookup by name."""
|
|
64
|
+
|
|
65
|
+
path: Path
|
|
66
|
+
whitespace: Whitespace | None
|
|
67
|
+
syntax: Syntax
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self, path: str | os.PathLike[str], *, whitespace: Whitespace | None = None, syntax: Syntax = Syntax.LATEX2E
|
|
71
|
+
) -> None:
|
|
72
|
+
object.__setattr__(self, "path", Path(path))
|
|
73
|
+
object.__setattr__(self, "whitespace", whitespace)
|
|
74
|
+
object.__setattr__(self, "syntax", syntax)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def template_text(ref: TemplateRef, module_file: str, owner: str) -> str:
|
|
78
|
+
"""The text of a template file; a relative path is relative to the module at `module_file` (`TM-M005`)."""
|
|
79
|
+
path = ref.path if ref.path.is_absolute() else Path(module_file).parent / ref.path
|
|
80
|
+
try:
|
|
81
|
+
return path.read_text(encoding="utf-8")
|
|
82
|
+
except OSError as exc:
|
|
83
|
+
raise DeclarationError(f"{owner}: cannot read {path}: {exc.strerror}", code="TM-M005", path=owner) from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclasses.dataclass(frozen=True)
|
|
87
|
+
class Library:
|
|
88
|
+
"""A block of TeX the `.sty` carries as it is (§21a 12), made by `Package.library`: `text` is dedented and has no
|
|
89
|
+
blank edges, and nothing is added or rewritten (`#1` stays `#1`). `owner` is the package that made it."""
|
|
90
|
+
|
|
91
|
+
name: str
|
|
92
|
+
text: str
|
|
93
|
+
syntax: Syntax
|
|
94
|
+
origin: str # "inline" or the template's file name, for the provenance comment
|
|
95
|
+
owner: object = dataclasses.field(compare=False, repr=False)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclasses.dataclass(frozen=True)
|
|
99
|
+
class Hole:
|
|
100
|
+
"""The argument `name` of the component whose body this is, as `#name` (M8)."""
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@enum.unique
|
|
106
|
+
class RefKind(enum.Enum):
|
|
107
|
+
CALL = "call" # a command: `\\acFact{...}`
|
|
108
|
+
OPEN = "open" # an environment's start: `\\begin{acBox}{...}`
|
|
109
|
+
CLOSE = "close" # an environment's end: `\\end{acBox}`
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclasses.dataclass(frozen=True)
|
|
113
|
+
class Ref:
|
|
114
|
+
"""A body piece that calls a declared component: `Fact.call(Hole("text"))`. `values` has one entry per argument of
|
|
115
|
+
`target`, in declared order (`None` for an optional one left out). Built by `call`, `open` and `close` on the
|
|
116
|
+
component class, which check the arguments; it is written with the target's prefix, and a target of another package
|
|
117
|
+
makes that package a requirement of this one (§21a 17)."""
|
|
118
|
+
|
|
119
|
+
target: type
|
|
120
|
+
kind: RefKind
|
|
121
|
+
values: tuple[object, ...]
|
|
122
|
+
whitespace: Whitespace | None = None # `None` is the package's default
|
|
123
|
+
|
|
124
|
+
def with_whitespace(self, whitespace: Whitespace) -> "Ref":
|
|
125
|
+
return dataclasses.replace(self, whitespace=whitespace)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
HOLE = re.compile(r"##|#(?P<name>[A-Za-z_]\w*)") # `##` is a literal `#`
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass_transform(frozen_default=True)
|
|
132
|
+
class Comment:
|
|
133
|
+
"""A comment (§21a 21): every line it renders starts with `% `, so no value can leave it, and nothing is escaped.
|
|
134
|
+
`Comment("note")` is a text; a subclass declares fields and a `template` with `#name` holes (`##` is a `#`):
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
class CardHeader(Comment):
|
|
138
|
+
'''The audit block above a card.'''
|
|
139
|
+
template = "Card: #name"
|
|
140
|
+
name: str
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
As a body piece it renders its lines in place; as a child of an environment or a top-level node of a document it is
|
|
144
|
+
transparent to the grammar. It claims no control sequence, so no package registers it. A hole that is not a field,
|
|
145
|
+
or a field the template never uses, is refused when the class is defined (`TM-M028`)."""
|
|
146
|
+
|
|
147
|
+
template: ClassVar[str] = "#text"
|
|
148
|
+
__tm_fields__: ClassVar[tuple[str, ...]] = ("text",)
|
|
149
|
+
text: str
|
|
150
|
+
|
|
151
|
+
def __init__(self, text: str) -> None:
|
|
152
|
+
object.__setattr__(self, "text", textwrap.dedent(text).strip("\n"))
|
|
153
|
+
|
|
154
|
+
def __init_subclass__(cls, **kwargs: object) -> None:
|
|
155
|
+
super().__init_subclass__(**kwargs)
|
|
156
|
+
if "template" not in cls.__dict__:
|
|
157
|
+
raise DeclarationError(f"{cls.__qualname__}: no template", code="TM-M028", path=cls.__name__)
|
|
158
|
+
cls.template = textwrap.dedent(cls.template).strip("\n")
|
|
159
|
+
dataclasses.dataclass(frozen=True)(cls)
|
|
160
|
+
cls.__tm_fields__ = tuple(f.name for f in dataclasses.fields(cls)) # pyright: ignore[reportArgumentType]
|
|
161
|
+
if "type" in cls.__tm_fields__:
|
|
162
|
+
raise DeclarationError(
|
|
163
|
+
f"{cls.__qualname__}.type: `type` names the component in data, so it cannot be a field",
|
|
164
|
+
code="TM-M030",
|
|
165
|
+
path=f"{cls.__name__}.type",
|
|
166
|
+
hint="call the field something else, e.g. `kind`",
|
|
167
|
+
)
|
|
168
|
+
holes = {m["name"] for m in HOLE.finditer(cls.template) if m["name"]}
|
|
169
|
+
if wrong := sorted(holes ^ set(cls.__tm_fields__)):
|
|
170
|
+
raise DeclarationError(
|
|
171
|
+
f"{cls.__qualname__}: holes and fields differ: {wrong} (a hole must be a field, a field must be used)",
|
|
172
|
+
code="TM-M028",
|
|
173
|
+
path=cls.__name__,
|
|
174
|
+
hint="use #name for each field, ## for a literal #",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
178
|
+
raise dataclasses.FrozenInstanceError(name)
|
|
179
|
+
|
|
180
|
+
def __eq__(self, other: object) -> bool:
|
|
181
|
+
return type(other) is type(self) and other.text == self.text # pyright: ignore[reportAttributeAccessIssue]
|
|
182
|
+
|
|
183
|
+
def __hash__(self) -> int:
|
|
184
|
+
return hash((type(self), self.text))
|
|
185
|
+
|
|
186
|
+
def __repr__(self) -> str:
|
|
187
|
+
return f"{type(self).__name__}({self.text!r})"
|
|
188
|
+
|
|
189
|
+
def validate(self) -> list[Violation]:
|
|
190
|
+
"""A comment breaks no rule."""
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
def lines(self) -> tuple[str, ...]:
|
|
194
|
+
"""The comment, one `% ` line per line of the filled template (a blank line is `%`)."""
|
|
195
|
+
filled = HOLE.sub(lambda m: "#" if m[0] == "##" else getattr(self, m["name"]), self.template)
|
|
196
|
+
return tuple(f"% {line}".rstrip() for line in filled.strip("\n").splitlines())
|
|
197
|
+
|
|
198
|
+
def render(self) -> str:
|
|
199
|
+
return "\n".join(self.lines())
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
_CONTROL_WORDS = {Syntax.LATEX2E: re.compile(r"[A-Za-z@]+"), Syntax.EXPL3: re.compile(r"[A-Za-z@_:]+")}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclasses.dataclass(frozen=True)
|
|
206
|
+
class Cs:
|
|
207
|
+
"""A control sequence, `Cs("par")` is `\\par`: the name is letters and `@` only, and in `Syntax.EXPL3` also `_` and
|
|
208
|
+
`:` (`TM-M027`)."""
|
|
209
|
+
|
|
210
|
+
name: str
|
|
211
|
+
syntax: Syntax = Syntax.LATEX2E
|
|
212
|
+
|
|
213
|
+
def __post_init__(self) -> None:
|
|
214
|
+
if not _CONTROL_WORDS[self.syntax].fullmatch(self.name):
|
|
215
|
+
raise DeclarationError(
|
|
216
|
+
f"Cs({self.name!r}): a {self.syntax.value} control word has "
|
|
217
|
+
+ ("letters, @, _ and : only" if self.syntax is Syntax.EXPL3 else "letters and @ only"),
|
|
218
|
+
code="TM-M027",
|
|
219
|
+
hint="write the name without the backslash, e.g. Cs('par')",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclasses.dataclass(frozen=True)
|
|
224
|
+
class Lit:
|
|
225
|
+
"""Text, escaped as a `Text` field is (T3): `Lit("50% & #1")` is `50\\% \\& \\#1`.
|
|
226
|
+
In `Syntax.EXPL3` a space is `~`."""
|
|
227
|
+
|
|
228
|
+
text: str
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclasses.dataclass(frozen=True, init=False)
|
|
232
|
+
class Group:
|
|
233
|
+
"""`{...}` around its pieces: balanced by construction. A piece is a `Group`, `Seq`, `Cs`, `Lit`, `Hole`, `Ref` or
|
|
234
|
+
a `Raw` (whose braces are already checked)."""
|
|
235
|
+
|
|
236
|
+
pieces: tuple["Inline", ...]
|
|
237
|
+
|
|
238
|
+
def __init__(self, *pieces: "Inline") -> None:
|
|
239
|
+
object.__setattr__(self, "pieces", pieces)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclasses.dataclass(frozen=True, init=False)
|
|
243
|
+
class Seq:
|
|
244
|
+
"""Pieces one after another with no braces: `Seq(Cs("textbf"), Group(Hole("text")))` is `\\textbf{#text}`."""
|
|
245
|
+
|
|
246
|
+
pieces: tuple["Inline", ...]
|
|
247
|
+
|
|
248
|
+
def __init__(self, *pieces: "Inline") -> None:
|
|
249
|
+
object.__setattr__(self, "pieces", pieces)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
type Inline = Group | Seq | Cs | Lit | Hole | Ref | Raw
|
|
253
|
+
type BodyPiece = TexRaw | str | TemplateRef | Ref | Comment | Group | Seq | Cs | Lit | Hole
|
|
254
|
+
type BodyT = BodyPiece | Sequence[BodyPiece] | Iterator[BodyPiece] | Generator[BodyPiece]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
_PIECES = (TexRaw, str, TemplateRef, Ref, Comment, Group, Seq, Cs, Lit, Hole)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def refs_in(piece: object) -> Iterator[Ref]:
|
|
261
|
+
"""Every `Ref` in a piece, nested ones included: they are what a body depends on."""
|
|
262
|
+
if isinstance(piece, Ref):
|
|
263
|
+
yield piece
|
|
264
|
+
elif isinstance(piece, Group | Seq):
|
|
265
|
+
for inner in piece.pieces:
|
|
266
|
+
yield from refs_in(inner)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def syntaxes_in(piece: object) -> Iterator[Syntax]:
|
|
270
|
+
"""The syntax every `TexRaw` and `Cs` in a piece names, nested ones included; other pieces have none."""
|
|
271
|
+
if isinstance(piece, TexRaw | Cs):
|
|
272
|
+
yield piece.syntax
|
|
273
|
+
elif isinstance(piece, Group | Seq):
|
|
274
|
+
for inner in piece.pieces:
|
|
275
|
+
yield from syntaxes_in(inner)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def text_of(text: str, syntax: Syntax) -> str:
|
|
279
|
+
"""`Lit` and argument text as the syntax reads it: expl3 ignores a space, so it is written `~`."""
|
|
280
|
+
return escape(text).replace(" ", "~") if syntax is Syntax.EXPL3 else escape(text)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _loose_start(piece: Inline) -> bool:
|
|
284
|
+
"""Can the text begin with a letter (a `Lit`, `Raw` or hole value), which a preceding control word would absorb?"""
|
|
285
|
+
if isinstance(piece, Seq):
|
|
286
|
+
return bool(piece.pieces) and _loose_start(piece.pieces[0])
|
|
287
|
+
return isinstance(piece, Lit | Raw | Hole)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _ends_with_cs(piece: Inline) -> bool:
|
|
291
|
+
if isinstance(piece, Seq):
|
|
292
|
+
return bool(piece.pieces) and _ends_with_cs(piece.pieces[-1])
|
|
293
|
+
return isinstance(piece, Cs)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def inline_text(piece: Inline, ref_text: Callable[[Ref], str], syntax: Syntax = Syntax.LATEX2E) -> str:
|
|
297
|
+
"""The TeX of a constructive piece. A space follows a control word when the next text could continue its name."""
|
|
298
|
+
if isinstance(piece, Lit):
|
|
299
|
+
return text_of(piece.text, syntax)
|
|
300
|
+
if isinstance(piece, Raw):
|
|
301
|
+
return piece.tex
|
|
302
|
+
if isinstance(piece, Hole):
|
|
303
|
+
return f"#{piece.name}"
|
|
304
|
+
if isinstance(piece, Ref):
|
|
305
|
+
return ref_text(piece)
|
|
306
|
+
if isinstance(piece, Cs):
|
|
307
|
+
return f"\\{piece.name}"
|
|
308
|
+
out: list[str] = []
|
|
309
|
+
for i, inner in enumerate(piece.pieces):
|
|
310
|
+
out.append(inline_text(inner, ref_text, syntax))
|
|
311
|
+
if i + 1 < len(piece.pieces) and _ends_with_cs(inner) and _loose_start(piece.pieces[i + 1]):
|
|
312
|
+
out.append(" ")
|
|
313
|
+
text = "".join(out)
|
|
314
|
+
return f"{{{text}}}" if isinstance(piece, Group) else text
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def pieces_of(value: object) -> tuple[BodyPiece, ...] | None:
|
|
318
|
+
"""The pieces of what a body gave, or `None` if it is not a body."""
|
|
319
|
+
if isinstance(value, _PIECES):
|
|
320
|
+
return (value,)
|
|
321
|
+
if isinstance(value, Sequence | Iterator):
|
|
322
|
+
found = tuple(value) # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType]
|
|
323
|
+
if all(isinstance(p, _PIECES) for p in found): # pyright: ignore[reportUnknownVariableType]
|
|
324
|
+
return found # pyright: ignore[reportUnknownVariableType]
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _trim(line: str) -> str:
|
|
329
|
+
line = line.rstrip()
|
|
330
|
+
if not line or line.lstrip().startswith("%") or (line.endswith("%") and not line.endswith("\\%")):
|
|
331
|
+
return line
|
|
332
|
+
return f"{line}%"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def join(
|
|
336
|
+
pieces: Sequence[TexRaw | Ref | Comment | Group | Seq | Cs | Lit | Hole],
|
|
337
|
+
default: Whitespace,
|
|
338
|
+
owner: str,
|
|
339
|
+
ref_text: Callable[[Ref], str],
|
|
340
|
+
syntax: Syntax = Syntax.LATEX2E,
|
|
341
|
+
) -> str:
|
|
342
|
+
"""The text of a body: pieces joined by newlines, each with its policy applied (none in `Syntax.EXPL3`, whose line
|
|
343
|
+
ends make no space); `ref_text` writes a `Ref`."""
|
|
344
|
+
out: list[str] = []
|
|
345
|
+
expl3 = syntax is Syntax.EXPL3
|
|
346
|
+
for piece in pieces:
|
|
347
|
+
if isinstance(piece, Comment):
|
|
348
|
+
out.extend(piece.lines())
|
|
349
|
+
continue
|
|
350
|
+
if not isinstance(piece, TexRaw): # a Ref or a constructive piece is one line, in the package's policy
|
|
351
|
+
policy = Whitespace.KEEP if expl3 else (piece.whitespace if isinstance(piece, Ref) else None) or default
|
|
352
|
+
text = inline_text(piece, ref_text, syntax)
|
|
353
|
+
out.append(_trim(text) if policy is Whitespace.TRIM else text)
|
|
354
|
+
continue
|
|
355
|
+
policy = Whitespace.KEEP if expl3 else piece.whitespace or default
|
|
356
|
+
if policy is Whitespace.TRIM and "\\verb" in piece.body:
|
|
357
|
+
raise DeclarationError(
|
|
358
|
+
f"{owner}: a TRIM piece contains \\verb, whose line ends matter",
|
|
359
|
+
code="TM-M025",
|
|
360
|
+
hint="give that piece Whitespace.KEEP",
|
|
361
|
+
)
|
|
362
|
+
lines = piece.body.splitlines()
|
|
363
|
+
out.extend(map(_trim, lines) if policy is Whitespace.TRIM else lines)
|
|
364
|
+
return "\n".join(out)
|
texmodel/bundle.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""`bundle` and `install` (G5): copy a manifest of files somewhere, refuse if any is missing, check it is current.
|
|
2
|
+
|
|
3
|
+
An `Item` is one file: where it comes from, where it goes (relative to the destination), and why. Where things go is
|
|
4
|
+
the caller's policy (a typeface must sit at a document's root because luaotfload reads no search path; a package can
|
|
5
|
+
sit anywhere kpathsea looks), so this module decides nothing about layout. To prove a bundle self-contained, compile it
|
|
6
|
+
with no asset roots and no font directories (texmodel-build can).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import pathlib
|
|
11
|
+
import shutil
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
|
|
14
|
+
from .errors import DeclarationError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclasses.dataclass(frozen=True)
|
|
18
|
+
class Item:
|
|
19
|
+
source: pathlib.Path # absolute, in the tree being exported
|
|
20
|
+
dest: pathlib.Path # relative to the bundle or target root
|
|
21
|
+
why: str = "" # one line, for the report and a README
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def present(self) -> bool:
|
|
25
|
+
return self.source.is_file()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def missing(items: Sequence[Item]) -> tuple[Item, ...]:
|
|
29
|
+
return tuple(item for item in items if not item.present)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def stale(items: Sequence[Item], target: pathlib.Path) -> tuple[Item, ...]:
|
|
33
|
+
"""The items `target` does not already hold, byte for byte. Content, not timestamps: a checkout rewrites mtimes."""
|
|
34
|
+
return tuple(
|
|
35
|
+
item
|
|
36
|
+
for item in items
|
|
37
|
+
if not ((target / item.dest).is_file() and (target / item.dest).read_bytes() == item.source.read_bytes())
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _inside(root: pathlib.Path, rel: pathlib.PurePath) -> pathlib.Path:
|
|
42
|
+
if rel.is_absolute() or ".." in rel.parts:
|
|
43
|
+
raise DeclarationError(f"{rel}: destination escapes {root}", code="TM-B002", hint="use a relative path")
|
|
44
|
+
return root / rel
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def write(
|
|
48
|
+
items: Sequence[Item], destination: pathlib.Path, generated: Mapping[str, str] | None = None
|
|
49
|
+
) -> tuple[pathlib.Path, ...]:
|
|
50
|
+
"""Copy `items` into `destination` and write `generated` (path -> text) beside them.
|
|
51
|
+
|
|
52
|
+
Refuses the whole export if any source is absent: a bundle that looks finished but lacks a file builds on a machine
|
|
53
|
+
that happens to have it installed, and fails everywhere else.
|
|
54
|
+
"""
|
|
55
|
+
if absent := missing(items):
|
|
56
|
+
names = "\n".join(f" {item.source}" for item in absent)
|
|
57
|
+
raise DeclarationError(f"{destination}: not written, missing sources:\n{names}", code="TM-B001")
|
|
58
|
+
written: list[pathlib.Path] = []
|
|
59
|
+
for item in items:
|
|
60
|
+
target = _inside(destination, item.dest)
|
|
61
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
shutil.copyfile(item.source, target) # the bytes, not a carried-over mtime
|
|
63
|
+
written.append(target)
|
|
64
|
+
for rel, text in (generated or {}).items():
|
|
65
|
+
target = _inside(destination, pathlib.PurePosixPath(rel))
|
|
66
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
target.write_text(text, encoding="utf-8")
|
|
68
|
+
written.append(target)
|
|
69
|
+
return tuple(written)
|
texmodel/cli.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""The `texmodel` command line. Only this module formats output (E5)."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import pathlib
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Annotated, Any
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from .config import Config, load_config
|
|
14
|
+
from .drift import check_drift
|
|
15
|
+
from .errors import DeclarationError, TexmodelError, ValidationError
|
|
16
|
+
from .package import Package, generator_version
|
|
17
|
+
from .plan import Plan
|
|
18
|
+
from .pointer import load_document, load_package
|
|
19
|
+
from .spec import json_schema
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(pretty_exceptions_enable=False, help="Typed models for LaTeX.", no_args_is_help=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback()
|
|
25
|
+
def main(verbose: int = typer.Option(0, "--verbose", "-v", count=True, help="-v info, -vv debug (to stderr).")) -> None:
|
|
26
|
+
"""Typed models for LaTeX."""
|
|
27
|
+
if verbose:
|
|
28
|
+
handler = logging.StreamHandler() # stderr; stdout carries results only
|
|
29
|
+
handler.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
|
|
30
|
+
log = logging.getLogger("texmodel")
|
|
31
|
+
log.addHandler(handler)
|
|
32
|
+
log.setLevel(logging.DEBUG if verbose > 1 else logging.INFO)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command()
|
|
36
|
+
def version() -> None:
|
|
37
|
+
"""Print the installed version."""
|
|
38
|
+
typer.echo(importlib.metadata.version("texmodel"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _exits(fn: Callable[..., None]) -> Callable[..., None]:
|
|
42
|
+
"""Errors print once, to stderr, with their code and hint; exit 2."""
|
|
43
|
+
|
|
44
|
+
def report(exc: TexmodelError) -> None:
|
|
45
|
+
where = f" at {exc.path}" if exc.path and isinstance(exc, ValidationError) else ""
|
|
46
|
+
typer.echo(f"error {exc.code}{where}: {exc}" + (f"\n hint: {exc.hint}" if exc.hint else ""), err=True)
|
|
47
|
+
|
|
48
|
+
@functools.wraps(fn)
|
|
49
|
+
def wrapper(*args: Any, **kwargs: Any) -> None:
|
|
50
|
+
try:
|
|
51
|
+
fn(*args, **kwargs)
|
|
52
|
+
except TexmodelError as exc:
|
|
53
|
+
report(exc)
|
|
54
|
+
raise typer.Exit(2) from exc
|
|
55
|
+
except ExceptionGroup as group: # an invalid document: every violation, not the first (E2)
|
|
56
|
+
problems = [e for e in group.exceptions if isinstance(e, TexmodelError)]
|
|
57
|
+
if len(problems) != len(group.exceptions):
|
|
58
|
+
raise
|
|
59
|
+
for exc in problems:
|
|
60
|
+
report(exc)
|
|
61
|
+
raise typer.Exit(2) from group
|
|
62
|
+
|
|
63
|
+
return wrapper
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _project() -> tuple[pathlib.Path, Config, Package]:
|
|
67
|
+
root = pathlib.Path.cwd()
|
|
68
|
+
config = load_config(root / "pyproject.toml")
|
|
69
|
+
if not config.package:
|
|
70
|
+
raise DeclarationError(
|
|
71
|
+
"no package declared",
|
|
72
|
+
code="TM-D023",
|
|
73
|
+
hint='set [tool.texmodel] package = "module:object" or "module:getter()"',
|
|
74
|
+
)
|
|
75
|
+
return root, config, load_package(config.package, root)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
@_exits
|
|
80
|
+
def build() -> None:
|
|
81
|
+
"""Write the package's generated files (the `.sty`) into the output directory."""
|
|
82
|
+
root, config, package = _project()
|
|
83
|
+
for rel, text in package.files().items():
|
|
84
|
+
path = root / config.output / rel
|
|
85
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
path.write_text(text, encoding="utf-8")
|
|
87
|
+
typer.echo(f"wrote {path.relative_to(root)}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _write(rendered: str | Plan, target: pathlib.Path) -> None:
|
|
91
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
target.write_text(rendered.text if isinstance(rendered, Plan) else rendered, encoding="utf-8")
|
|
93
|
+
typer.echo(f"wrote {target}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command()
|
|
97
|
+
@_exits
|
|
98
|
+
def schema() -> None:
|
|
99
|
+
"""Write the JSON Schema of the package's data (every component and value) to `schema_output`."""
|
|
100
|
+
root, config, package = _project()
|
|
101
|
+
if not config.schema_output:
|
|
102
|
+
raise DeclarationError(
|
|
103
|
+
"no schema_output declared", code="TM-D033", hint='set [tool.texmodel] schema_output = "schema.json"'
|
|
104
|
+
)
|
|
105
|
+
path = root / config.schema_output
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
path.write_text(
|
|
108
|
+
json.dumps(json_schema(package), indent=2, sort_keys=True, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
109
|
+
)
|
|
110
|
+
typer.echo(f"wrote {path.relative_to(root)}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
@_exits
|
|
115
|
+
def render(
|
|
116
|
+
document: Annotated[
|
|
117
|
+
str | None,
|
|
118
|
+
typer.Argument(
|
|
119
|
+
help="Pointer (module:object, module:getter()) to a str or Plan from Package.document() / plan(). "
|
|
120
|
+
"Without one, every [tool.texmodel.documents] entry is written to its path."
|
|
121
|
+
),
|
|
122
|
+
] = None,
|
|
123
|
+
output: Annotated[pathlib.Path | None, typer.Option("--output", "-o", help="Default <attribute>.tex.")] = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Write a document to a .tex file, or all configured documents. An invalid document reports every violation and
|
|
126
|
+
exits 2."""
|
|
127
|
+
root = pathlib.Path.cwd()
|
|
128
|
+
if document is not None:
|
|
129
|
+
target = output or pathlib.Path(f"{document.partition(':')[2].removesuffix('()').rpartition('.')[2]}.tex")
|
|
130
|
+
_write(load_document(document, root), target)
|
|
131
|
+
return
|
|
132
|
+
documents = load_config(root / "pyproject.toml").documents
|
|
133
|
+
if output or not documents:
|
|
134
|
+
raise DeclarationError(
|
|
135
|
+
"no document to render" if not documents else "--output needs a document pointer",
|
|
136
|
+
code="TM-D031",
|
|
137
|
+
hint='pass a pointer, or declare [tool.texmodel.documents] "deck.tex" = "module:plan()"',
|
|
138
|
+
)
|
|
139
|
+
for name, pointer in sorted(documents.items()):
|
|
140
|
+
_write(load_document(pointer, root), root / name)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _check_interface(root: pathlib.Path, config: Config, package: Package) -> bool:
|
|
144
|
+
"""An adopted package (M9): every declared component is defined in `sources` with its declared signature."""
|
|
145
|
+
paths = sorted(p for glob in config.sources for p in root.glob(glob) if p.is_file())
|
|
146
|
+
if not paths:
|
|
147
|
+
raise DeclarationError(
|
|
148
|
+
f"{package.name} is adopted but `sources` matches no file",
|
|
149
|
+
code="TM-D030",
|
|
150
|
+
hint='set [tool.texmodel] sources = ["res/brand/*.sty"]',
|
|
151
|
+
)
|
|
152
|
+
violations = package.check_interface(*(p.read_text(encoding="utf-8", errors="replace") for p in paths))
|
|
153
|
+
for v in violations:
|
|
154
|
+
typer.echo(f"{v.code} {v.path}: {v.message}")
|
|
155
|
+
if not violations:
|
|
156
|
+
typer.echo(f"ok: {len(paths)} source file(s) define what {package.name} declares")
|
|
157
|
+
return bool(violations)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _check_generated(root: pathlib.Path, config: Config, package: Package) -> bool:
|
|
161
|
+
out = root / config.output
|
|
162
|
+
drift = check_drift(package.files(), out, owned=("*.sty",), version=generator_version())
|
|
163
|
+
for label, names in (("stale", drift.stale), ("missing", drift.missing), ("orphaned", drift.orphaned)):
|
|
164
|
+
for name in names:
|
|
165
|
+
typer.echo(f"{label:8} {config.output}/{name}")
|
|
166
|
+
plan = package.render_plan()
|
|
167
|
+
for name in drift.stale: # a stale file is named by component, as a document is
|
|
168
|
+
for e in plan.edits((out / name).read_text(encoding="utf-8", errors="replace")):
|
|
169
|
+
typer.echo(f"{e.kind.value:8} {config.output}/{name} {e.path}: {e.found or e.expected}")
|
|
170
|
+
for note in drift.notes:
|
|
171
|
+
typer.echo(f"note {note}")
|
|
172
|
+
if drift.stale or drift.missing or drift.orphaned:
|
|
173
|
+
typer.echo("run `texmodel build` and commit the result", err=True)
|
|
174
|
+
return True
|
|
175
|
+
typer.echo("ok: generated files are current")
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _check_documents(root: pathlib.Path, config: Config) -> bool:
|
|
180
|
+
"""Each committed document against what the model renders now, by path (like `terraform plan`): a changed, removed
|
|
181
|
+
or added line names the component that writes it, so a hand edit and a stale render read the same."""
|
|
182
|
+
failed = False
|
|
183
|
+
for name, pointer in sorted(config.documents.items()):
|
|
184
|
+
rendered = load_document(pointer, root)
|
|
185
|
+
committed = root / name
|
|
186
|
+
if not committed.is_file():
|
|
187
|
+
typer.echo(f"missing {name}")
|
|
188
|
+
failed = True
|
|
189
|
+
elif isinstance(rendered, Plan):
|
|
190
|
+
for e in rendered.edits(committed.read_text(encoding="utf-8")):
|
|
191
|
+
typer.echo(f"{e.kind.value:8} {name} {e.path}: {e.found or e.expected}")
|
|
192
|
+
failed = True
|
|
193
|
+
elif committed.read_text(encoding="utf-8") != rendered:
|
|
194
|
+
typer.echo(f"stale {name}")
|
|
195
|
+
failed = True
|
|
196
|
+
if config.documents and not failed:
|
|
197
|
+
typer.echo(f"ok: {len(config.documents)} document(s) match the model")
|
|
198
|
+
if failed:
|
|
199
|
+
typer.echo("run `texmodel render` and commit the result", err=True)
|
|
200
|
+
return failed
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command()
|
|
204
|
+
@_exits
|
|
205
|
+
def check() -> None:
|
|
206
|
+
"""Fail if a generated file is stale, missing or orphaned, an adopted package's interface drifted, or a document
|
|
207
|
+
differs from what the model renders. Exit 0 ok, 1 drift."""
|
|
208
|
+
root, config, package = _project()
|
|
209
|
+
failed = _check_interface(root, config, package) if package.adopted else _check_generated(root, config, package)
|
|
210
|
+
if _check_documents(root, config) or failed:
|
|
211
|
+
raise typer.Exit(1)
|