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