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/config.py ADDED
@@ -0,0 +1,32 @@
1
+ """`[tool.texmodel]` in pyproject.toml: the one configuration source (U0)."""
2
+
3
+ import dataclasses
4
+ import pathlib
5
+ import tomllib
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import Any
8
+
9
+ from .errors import DeclarationError
10
+
11
+
12
+ @dataclasses.dataclass(frozen=True, kw_only=True)
13
+ class Config:
14
+ package: str = "" # pointer to the Package to build: "module:object", "module:ns.object" or "module:getter()"
15
+ schema_output: str = "" # where `texmodel schema` writes the JSON Schema of the package's data
16
+ output: str = "generated" # where `texmodel build` writes, relative to pyproject.toml
17
+ # committed .tex -> pointer to what renders it
18
+ documents: Mapping[str, str] = dataclasses.field(default_factory=dict[str, str])
19
+ sources: Sequence[str] = () # globs of the hand-written TeX an adopted package declares (M9), from pyproject.toml
20
+
21
+
22
+ def load_config(pyproject: pathlib.Path) -> Config:
23
+ """Read and strictly validate `[tool.texmodel]`; unknown keys are refused."""
24
+ table: dict[str, Any] | None = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("tool", {}).get("texmodel")
25
+ if table is None:
26
+ raise DeclarationError(f"{pyproject}: no [tool.texmodel] table", code="TM-D001", hint="add [tool.texmodel]")
27
+ known = {f.name for f in dataclasses.fields(Config)}
28
+ if unknown := sorted(set(table) - known):
29
+ raise DeclarationError(
30
+ f"unknown [tool.texmodel] keys: {unknown}", code="TM-D002", hint=f"known: {sorted(known)}"
31
+ )
32
+ return Config(**table)
texmodel/drift.py ADDED
@@ -0,0 +1,55 @@
1
+ """Drift (G6): are the committed generated files exactly what the generators would write now?"""
2
+
3
+ import dataclasses
4
+ import pathlib
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+
8
+ _STAMP = re.compile(r"generated with texmodel (\S+)")
9
+
10
+
11
+ def stamp(version: str) -> str:
12
+ """The line a generated file's header carries, so `check_drift` can say which version wrote it (G7)."""
13
+ return f"generated with texmodel {version}"
14
+
15
+
16
+ @dataclasses.dataclass(frozen=True)
17
+ class Drift:
18
+ stale: tuple[str, ...] = () # exists, differs from what would be written (a hand edit, or an older generator)
19
+ missing: tuple[str, ...] = () # should exist, does not
20
+ orphaned: tuple[str, ...] = () # exists under an owned glob, nothing generates it
21
+ notes: tuple[str, ...] = () # e.g. "x.sty: generated with texmodel 0.1.0, current is 0.2.0: run `texmodel build`"
22
+
23
+ @property
24
+ def ok(self) -> bool:
25
+ return not (self.stale or self.missing or self.orphaned)
26
+
27
+
28
+ def check_drift(
29
+ expected: Mapping[str, str | bytes],
30
+ root: pathlib.Path,
31
+ owned: Sequence[str] = ("**/*",),
32
+ *,
33
+ version: str | None = None,
34
+ ) -> Drift:
35
+ """Compare `expected` (path relative to `root` -> content) byte for byte with what is on disk.
36
+
37
+ `owned` are globs under `root` that only generators write; any file there that is not expected is orphaned.
38
+ """
39
+ stale_: list[str] = []
40
+ missing_: list[str] = []
41
+ notes: list[str] = []
42
+ for rel, content in sorted(expected.items()):
43
+ path = root / rel
44
+ want = content.encode("utf-8") if isinstance(content, str) else content
45
+ if not path.is_file():
46
+ missing_.append(rel)
47
+ elif path.read_bytes() != want:
48
+ stale_.append(rel)
49
+ written_by = _STAMP.search(path.read_text(encoding="utf-8", errors="replace"))
50
+ if version and written_by and written_by[1] != version:
51
+ notes.append(
52
+ f"{rel}: generated with texmodel {written_by[1]}, current is {version}: run `texmodel build`"
53
+ )
54
+ present = {p.relative_to(root).as_posix() for glob in owned for p in root.glob(glob) if p.is_file()}
55
+ return Drift(tuple(stale_), tuple(missing_), tuple(sorted(present - expected.keys())), tuple(notes))
texmodel/errors.py ADDED
@@ -0,0 +1,38 @@
1
+ """The error hierarchy (E1): every error carries a stable code, a path into the Doc and a fix hint."""
2
+
3
+ import dataclasses
4
+
5
+
6
+ class TexmodelError(Exception):
7
+ """Base of every error texmodel (and texmodel-build) raises."""
8
+
9
+ def __init__(self, message: str, *, code: str = "TM-E000", path: str = "", hint: str = "") -> None:
10
+ super().__init__(message)
11
+ self.code = code
12
+ self.path = path
13
+ self.hint = hint
14
+
15
+
16
+ class DeclarationError(TexmodelError):
17
+ """A component, package or `[tool.texmodel]` declaration is invalid."""
18
+
19
+
20
+ class ValidationError(TexmodelError):
21
+ """A Doc or spec breaks a rule."""
22
+
23
+
24
+ class MigrationError(TexmodelError):
25
+ """A spec cannot be migrated to the current schema."""
26
+
27
+
28
+ @dataclasses.dataclass(frozen=True)
29
+ class Violation:
30
+ """One broken rule in a Doc (D5, E2): `validate()` returns them all; raising APIs raise an `ExceptionGroup`."""
31
+
32
+ code: str
33
+ path: str
34
+ message: str
35
+ hint: str = ""
36
+
37
+ def error(self) -> ValidationError:
38
+ return ValidationError(self.message, code=self.code, path=self.path, hint=self.hint)
texmodel/package.py ADDED
@@ -0,0 +1,482 @@
1
+ """A `Package` is the container: its commands render to one `.sty` (G1), deterministically.
2
+
3
+ An adopted package (M9) is the other way round: the `.sty` is hand-written, the components only declare its interface,
4
+ and `check_interface` compares the two.
5
+ """
6
+
7
+ import dataclasses
8
+ import importlib.metadata
9
+ import inspect
10
+ import re
11
+ import textwrap
12
+ import typing
13
+ from collections.abc import Iterator, Sequence
14
+
15
+ from ._internal.render import (
16
+ EnvSpec,
17
+ Spec,
18
+ ValueSpec,
19
+ bool_names,
20
+ check_references,
21
+ colour_name,
22
+ header_lines,
23
+ lint_library,
24
+ render_command,
25
+ render_environment,
26
+ render_library,
27
+ render_value,
28
+ require_lines,
29
+ setting,
30
+ signature,
31
+ undeclared,
32
+ )
33
+ from ._internal.scan import scan_declarations
34
+ from .body import Library, Syntax, TemplateRef, Whitespace, template_text
35
+ from .components import AnyValue, Command, Environment, Node, Passthrough, env_spec_of, spec_of, value_spec_of
36
+ from .drift import stamp
37
+ from .errors import DeclarationError, Violation
38
+ from .plan import Line, Plan, node_lines
39
+ from .preamble import Entry, PdfInfo, RequireMetadata, tex_of
40
+ from .testing import Component, stub_tex
41
+ from .types import Length, Raw, balanced
42
+
43
+ _NAME = re.compile(r"[a-z][a-z0-9-]*")
44
+ _PREFIX = re.compile(r"[a-z]*") # empty: the package of a standard TeX library, whose names carry none
45
+ _OPTION = re.compile(r"[A-Za-z0-9][A-Za-z0-9=.-]*") # a class option is TeX, so it is not free text
46
+ _DATE = re.compile(r"\d{4}/\d{2}/\d{2}") # \ProvidesPackage's date slot: YYYY/MM/DD (G3)
47
+
48
+
49
+ _LIBRARY_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]*")
50
+ _BAD_OPTION = re.compile(r"[%\\\n]") # a comment, a control sequence or a line end
51
+
52
+
53
+ def _package_option(option: str) -> bool:
54
+ """One package option as written in `[...]`: `table`, `margin=10mm`, `colorprofiles={A=a.icc}`. Braces balance, and
55
+ outside braces nothing ends the option list early."""
56
+ outside = re.sub(r"\{[^{}]*\}", "", option)
57
+ return (
58
+ bool(option.strip())
59
+ and balanced(option)
60
+ and not _BAD_OPTION.search(option)
61
+ and not re.search(r"[\[\]]", outside)
62
+ )
63
+
64
+
65
+ def _passthroughs(node: Node, path: str) -> Iterator[tuple[str, Passthrough]]:
66
+ """Every `Passthrough` in a tree with its plan path."""
67
+ if isinstance(node, Passthrough):
68
+ yield path, node
69
+ elif isinstance(node, Environment):
70
+ for i, child in enumerate(node.content):
71
+ yield from _passthroughs(child, f"{path}/{type(child).__name__}[{i}]")
72
+
73
+
74
+ @dataclasses.dataclass(frozen=True)
75
+ class PreambleSlots:
76
+ """TeX a document places around its class and package lines (D3), each slot a tuple of `Raw` or a typed entry
77
+ (`InputPath`, `GraphicsPath`, `PassOption`, `RequireMetadata`, `PdfInfo`; one in a slot it does not belong in is
78
+ `TM-C008`).
79
+
80
+ `before_class` is where `\\DocumentMetadata` must go; `before_package` is for what the package needs when it
81
+ loads (`\\input@path`, package options); `after_package` is for what needs it loaded (`\\graphicspath`,
82
+ per-document `\\renewcommand`s). Public names of the package used here must be declared (M8).
83
+ """
84
+
85
+ before_class: tuple[Raw | Entry, ...] = ()
86
+ before_package: tuple[Raw | Entry, ...] = ()
87
+ after_package: tuple[Raw | Entry, ...] = ()
88
+
89
+ def items(self) -> Iterator[tuple[str, int, Raw | Entry]]:
90
+ """Every item with its slot name and index, in document order: the path is `preamble.<slot>[<index>]`."""
91
+ for slot, found in (
92
+ ("before_class", self.before_class),
93
+ ("before_package", self.before_package),
94
+ ("after_package", self.after_package),
95
+ ):
96
+ for i, item in enumerate(found):
97
+ yield slot, i, item
98
+
99
+
100
+ def generator_version() -> str:
101
+ """`X.Y.Z` of the installed texmodel, without dev or local parts, so output does not churn per commit (G7)."""
102
+ m = re.match(r"\d+\.\d+\.\d+", importlib.metadata.version("texmodel"))
103
+ return m[0] if m else "0.0.0"
104
+
105
+
106
+ class Package:
107
+ def __init__(
108
+ self,
109
+ name: str,
110
+ *,
111
+ prefix: str,
112
+ version: str = "0.0.0",
113
+ date: str = "1970/01/01",
114
+ description: str = "",
115
+ document_class: str = "article",
116
+ adopted: bool = False,
117
+ builtin: bool = False,
118
+ whitespace: Whitespace = Whitespace.KEEP,
119
+ ) -> None:
120
+ if not _NAME.fullmatch(name):
121
+ raise DeclarationError(f"package name {name!r} must match [a-z][a-z0-9-]*", code="TM-P001")
122
+ if not _PREFIX.fullmatch(prefix):
123
+ raise DeclarationError(f"prefix {prefix!r} must be lowercase letters, or empty", code="TM-P002")
124
+ if not _DATE.fullmatch(date):
125
+ raise DeclarationError(f"date {date!r} must be YYYY/MM/DD (\\ProvidesPackage's date slot)", code="TM-P003")
126
+ if not _NAME.fullmatch(document_class):
127
+ raise DeclarationError(f"document class {document_class!r} must match [a-z][a-z0-9-]*", code="TM-P004")
128
+ self.name, self.prefix, self.version, self.date, self.description = name, prefix, version, date, description
129
+ self.document_class = document_class
130
+ self.whitespace = whitespace # the policy of a body piece that names none (M8)
131
+ self.adopted = adopted # the `.sty` is hand-written; components only declare it (M9)
132
+ self.builtin = builtin # always loaded (the LaTeX kernel): calling it requires no package
133
+ self._requires: list[tuple[Package, tuple[str, ...]]] = [] # `require()`, in order
134
+ self._items: list[Spec | EnvSpec | ValueSpec | Library] = [] # registration order is output order
135
+ self._controls: dict[str, str] = {} # control sequence -> qualname of its declarer
136
+ self._calls: list[type] = [] # components its bodies call, in order (§21a 17)
137
+ self._colours: dict[str, str] = {} # colour name -> qualname; its own namespace
138
+ self._classes: list[type] = [] # every registered class, in registration order
139
+
140
+ def _add(
141
+ self, spec: Spec | EnvSpec | ValueSpec, *claims: tuple[dict[str, str], str, str], rendered: bool = True
142
+ ) -> None:
143
+ """Claim names (table, key, how to show it); a collision is an error here, not at \\begin{document} (M7). A
144
+ component a library defines claims its name and renders nothing."""
145
+ for table, key, shown in claims:
146
+ if key in table:
147
+ raise DeclarationError(
148
+ f"{spec.qualname}: {shown} is already declared by {table[key]}", code="TM-M007", path=spec.name
149
+ )
150
+ for table, key, _ in claims:
151
+ table[key] = spec.qualname
152
+ if rendered:
153
+ self._items.append(spec)
154
+ if not isinstance(spec, ValueSpec):
155
+ self._calls.extend(spec.refs)
156
+
157
+ def command[C: type[Command]](self, cls: C) -> C:
158
+ """Register a command class."""
159
+ spec = spec_of(cls, declared_only=self.adopted, whitespace=self.whitespace)
160
+ self._own_library(cls)
161
+ if not self.adopted and cls.defined_by is None:
162
+ render_command(self.prefix, spec) # lints the body now, so a bad body is refused at import
163
+ control = self._control(spec)
164
+ self._add(spec, (self._controls, control, f"\\{control}"), rendered=cls.defined_by is None)
165
+ cls.__tm_prefix__, cls.__tm_package__, cls.__tm_cs__ = self.prefix, self, control
166
+ self._classes.append(cls)
167
+ return cls
168
+
169
+ def _own_library(self, cls: type[Command] | type[Environment]) -> None:
170
+ """The block that defines a component is this package's (`TM-M033`)."""
171
+ if cls.defined_by is not None and cls.defined_by.owner is not self:
172
+ raise DeclarationError(
173
+ f"{cls.__qualname__}: defined_by {cls.defined_by.name!r}, a library of another package",
174
+ code="TM-M033",
175
+ path=cls.__name__,
176
+ hint="declare the component in the package whose library defines it",
177
+ )
178
+
179
+ def library(self, name: str, source: str | TemplateRef, *, syntax: Syntax = Syntax.LATEX2E) -> Library:
180
+ """TeX carried into the `.sty` as it is (§21a 12), among the items in registration order: dedented, blank edges
181
+ removed, `%` and `#name` untouched. `Syntax.EXPL3` wraps it in `\\ExplSyntaxOn` / `\\ExplSyntaxOff`. A relative
182
+ `TemplateRef` is relative to the calling module. Name a component's `defined_by` to declare what it defines."""
183
+ if not _LIBRARY_NAME.fullmatch(name):
184
+ raise DeclarationError(
185
+ f"library name {name!r} must match [A-Za-z][A-Za-z0-9_-]*", code="TM-M001", path=name
186
+ )
187
+ if any(isinstance(i, Library) and i.name == name for i in self._items):
188
+ raise DeclarationError(f"library {name!r} is already declared", code="TM-M007", path=name)
189
+ origin = "inline"
190
+ if isinstance(source, TemplateRef):
191
+ source, origin = template_text(source, inspect.stack(0)[1].filename, name), source.path.name
192
+ lib = Library(name, textwrap.dedent(source).strip("\n"), syntax, origin, self)
193
+ lint_library(lib)
194
+ self._items.append(lib)
195
+ return lib
196
+
197
+ def _control(self, spec: Spec | EnvSpec) -> str:
198
+ """The control sequence: its `cs`, else prefix + class name (which needs a prefix)."""
199
+ if spec.cs is None and not self.prefix:
200
+ raise DeclarationError(
201
+ f"{spec.qualname}: a package with no prefix names its commands with `cs`",
202
+ code="TM-P011",
203
+ path=spec.name,
204
+ hint=f'add cs = "..." (the TeX name, e.g. {spec.name.lower()!r}) to the class',
205
+ )
206
+ return spec.cs or self.prefix + spec.name
207
+
208
+ def environment[E: type[Environment]](self, cls: E) -> E:
209
+ """Register an environment class: `\\newenvironment` with its begin and end bodies."""
210
+ spec = env_spec_of(cls, declared_only=self.adopted, whitespace=self.whitespace)
211
+ self._own_library(cls)
212
+ if not self.adopted and cls.defined_by is None:
213
+ render_environment(self.prefix, spec) # lints both bodies now
214
+ control = self._control(spec)
215
+ self._add(spec, (self._controls, control, f"\\{control}"), rendered=cls.defined_by is None)
216
+ cls.__tm_prefix__, cls.__tm_package__, cls.__tm_cs__ = self.prefix, self, control
217
+ self._classes.append(cls)
218
+ return cls
219
+
220
+ def value[V: type[AnyValue]](self, cls: V) -> V:
221
+ """Register a value class: `\\newlength`, `\\definecolor`, `\\newif` or `\\newcommand` by its type (M1a)."""
222
+ spec = value_spec_of(cls)
223
+ if self.adopted and spec.kind not in ("Text", "Raw"):
224
+ raise DeclarationError(
225
+ f"{cls.__qualname__}: an adopted package can declare a Value[Text] or Value[Raw] only",
226
+ code="TM-P005",
227
+ path=spec.name,
228
+ hint="declare a length, colour or flag the hand-written package defines as a Command with no arguments",
229
+ )
230
+ if spec.default is None and not self.adopted:
231
+ raise DeclarationError(f"{cls.__qualname__}: no default", code="TM-M013", path=spec.name)
232
+ control = self.prefix + spec.name
233
+ if isinstance(spec.default, Length | str | Raw | None):
234
+ claims = [(self._controls, control, f"\\{control}")]
235
+ elif isinstance(spec.default, bool):
236
+ claims = [(self._controls, c, f"\\{c}") for c in (control, *bool_names(self.prefix, spec.name))]
237
+ else:
238
+ name = colour_name(self.prefix, spec.name)
239
+ claims = [(self._colours, name, f"colour {name}")]
240
+ self._add(spec, *claims)
241
+ cls.__tm_prefix__ = self.prefix
242
+ self._classes.append(cls)
243
+ return cls
244
+
245
+ @property
246
+ def registered(self) -> tuple[type, ...]:
247
+ """Every registered command, environment and value class, in registration order."""
248
+ return tuple(self._classes)
249
+
250
+ def require(self, package: "Package", *, options: Sequence[str] = ()) -> None:
251
+ """Load `package` with `\\RequirePackage[options]{name}` in this package's `.sty`, before the packages its
252
+ components need. For a library whose commands bodies do not call (microtype, hyphenat) or that takes options."""
253
+ if package.builtin:
254
+ raise DeclarationError(f"{package.name} is always loaded", code="TM-P008", path=self.name)
255
+ if bad := [o for o in options if not _package_option(o)]:
256
+ raise DeclarationError(
257
+ f"package option {bad[0]!r}: not empty, balanced braces, no % or \\ or [ ] outside braces",
258
+ code="TM-P009",
259
+ hint="e.g. 'table', 'margin=10mm', 'colorprofiles={A=a.icc,X=b.icc}'",
260
+ )
261
+ if any(p is package for p, _ in self._requires):
262
+ raise DeclarationError(f"{package.name} is already required by {self.name}", code="TM-P010", path=self.name)
263
+ self._requires.append((package, tuple(options)))
264
+
265
+ @property
266
+ def depends_on(self) -> tuple["Package", ...]:
267
+ """The other packages this one requires or whose components its bodies call (§21a 17), in first-use order."""
268
+ called = (typing.cast("Package", t.__tm_package__) for t in self._calls) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
269
+ found = (*(p for p, _ in self._requires), *called)
270
+ return tuple(dict.fromkeys(p for p in found if p is not self and not p.builtin))
271
+
272
+ def require_entries(self) -> list[tuple[str, tuple[str, ...]]]:
273
+ """What the `.sty` loads: `require()`d packages in order with their options, then, sorted, the packages the
274
+ values need (M14) and the components call that are not already loaded; computed, never collected by mutation."""
275
+ explicit = [(p.name, o) for p, o in self._requires]
276
+ needs = {p for s in self._items if isinstance(s, ValueSpec) for p in s.needs}
277
+ rest = sorted({*needs, *(p.name for p in self.depends_on)} - {n for n, _ in explicit})
278
+ return [*explicit, *((n, ()) for n in rest)]
279
+
280
+ @property
281
+ def required_packages(self) -> tuple[str, ...]:
282
+ """The names `require_entries` loads, in order."""
283
+ return tuple(n for n, _ in self.require_entries())
284
+
285
+ def _check_cycles(self, path: tuple["Package", ...] = ()) -> None:
286
+ """A package that (through others) requires itself cannot load (`TM-P007`)."""
287
+ if self in path:
288
+ chain = " -> ".join(p.name for p in (*path[path.index(self) :], self))
289
+ raise DeclarationError(f"packages require each other: {chain}", code="TM-P007", path=self.name)
290
+ for dependency in self.depends_on:
291
+ dependency._check_cycles((*path, self)) # noqa: SLF001
292
+
293
+ def render(self, *, version: str | None = None) -> str:
294
+ """The `.sty` text, that of `render_plan(version=)`."""
295
+ return self.render_plan(version=version).text
296
+
297
+ def render_plan(self, *, version: str | None = None) -> Plan:
298
+ """The `.sty` as lines with provenance (§21a 11): `header`, `requires`, then one path per component or value
299
+ (`Fact`, `FactGap`); the blank line before an item is the item's. `version` is the generator's, recorded in the
300
+ header; default the installed texmodel's.
301
+
302
+ Body references are checked here, not at registration: a body may name a component declared after it, so the
303
+ family is only complete once someone asks for output (`files()` and `texmodel check` both do) (M8, G2).
304
+ """
305
+ if self.adopted:
306
+ raise DeclarationError(
307
+ f"{self.name}: an adopted package renders no .sty", code="TM-P006", hint="the hand-written one is it"
308
+ )
309
+ self._check_cycles()
310
+ for s in self._items:
311
+ if isinstance(s, Spec):
312
+ check_references(self.prefix, s, s.body, self._controls)
313
+ elif isinstance(s, EnvSpec):
314
+ check_references(self.prefix, s, s.begin + "\n" + s.end, self._controls)
315
+ head = header_lines(self.name, self.version, self.date, self.description, stamp(version or generator_version()))
316
+ requires = require_lines(self.require_entries())
317
+ lines = [
318
+ *(Line("header", tex) for tex in head),
319
+ Line("requires", ""),
320
+ *(Line("requires", tex) for tex in requires),
321
+ *([Line("requires", "")] if requires else []),
322
+ ]
323
+ if not self._items:
324
+ lines.append(Line("requires", "")) # the file ends with the empty body's own blank line
325
+ for n, s in enumerate(self._items):
326
+ text = self._render_item(s).splitlines()
327
+ lines += [Line(s.name, ""), *(Line(s.name, tex) for tex in text)] if n else [Line(s.name, t) for t in text]
328
+ return Plan(tuple(lines))
329
+
330
+ def _render_item(self, s: Spec | EnvSpec | ValueSpec | Library) -> str:
331
+ if isinstance(s, Library):
332
+ return render_library(s)
333
+ if isinstance(s, ValueSpec):
334
+ return render_value(self.prefix, s)
335
+ return render_command(self.prefix, s) if isinstance(s, Spec) else render_environment(self.prefix, s)
336
+
337
+ def check_interface(self, *sources: str) -> list[Violation]:
338
+ """Every declared component is defined in `sources` (the hand-written TeX) with the signature declared (M9).
339
+
340
+ Read by `texmodel check` for an adopted package. A name that `sources` define some other way than
341
+ `\\newcommand`, `\\providecommand` or `\\newenvironment` counts as missing.
342
+ """
343
+ found = scan_declarations("\n".join(sources))
344
+ out: list[Violation] = []
345
+ for s in (i for i in self._items if not isinstance(i, Library)):
346
+ control, want = (
347
+ (s.cs or self.prefix + s.name) if not isinstance(s, ValueSpec) else self.prefix + s.name,
348
+ "" if isinstance(s, ValueSpec) else signature(s.args),
349
+ )
350
+ if control not in found:
351
+ out.append(Violation("TM-C010", s.name, f"\\{control} is declared but the sources do not define it"))
352
+ elif found[control] != want:
353
+ out.append(
354
+ Violation(
355
+ "TM-C011",
356
+ s.name,
357
+ f"\\{control}: declared {want or 'without arguments'}, the sources define "
358
+ f"{found[control] or 'no arguments'}",
359
+ hint="change the declaration or the package so they agree",
360
+ )
361
+ )
362
+ return out
363
+
364
+ def plan(
365
+ self,
366
+ *body: Node,
367
+ settings: Sequence[AnyValue] = (),
368
+ options: Sequence[str] = (),
369
+ preamble: PreambleSlots | None = None,
370
+ ) -> Plan:
371
+ """A complete `.tex` as lines with provenance (D1a, D2, B2): pure, deterministic, no I/O. `settings` set values
372
+ for the whole document.
373
+
374
+ Everything wrong is reported at once, as an `ExceptionGroup` of `ValidationError` (E2).
375
+ """
376
+ preamble = preamble or PreambleSlots()
377
+ problems = [v for node in body for v in node.validate()]
378
+ problems += [
379
+ Violation("TM-M015", path, f"\\{word} is not declared by this package", hint="declare it, or fix the name")
380
+ for i, node in enumerate(body)
381
+ for path, passthrough in _passthroughs(node, f"{type(node).__name__}[{i}]")
382
+ for word in undeclared(self.prefix, passthrough.tex.tex, self._controls)
383
+ ]
384
+ problems += [
385
+ Violation(
386
+ "TM-M015",
387
+ f"preamble.{slot}[{i}]",
388
+ f"\\{word} is not declared by this package",
389
+ hint="declare it, or fix the name",
390
+ )
391
+ for slot, i, item in preamble.items()
392
+ for word in undeclared(self.prefix, tex_of(item).tex, self._controls)
393
+ ]
394
+ problems += [
395
+ Violation(
396
+ "TM-C008",
397
+ f"preamble.{slot}[{i}]",
398
+ f"{type(item).__name__} does not belong in {slot}",
399
+ hint=f"put it in {' or '.join(type(item).slots)}",
400
+ )
401
+ for slot, i, item in preamble.items()
402
+ if not isinstance(item, Raw) and slot not in type(item).slots
403
+ ]
404
+ problems += [
405
+ Violation(
406
+ "TM-C009",
407
+ f"preamble.{slot}[{i}]",
408
+ "PdfInfo needs \\DocumentMetadata, which no RequireMetadata in before_class provides",
409
+ hint="add RequireMetadata() to before_class",
410
+ )
411
+ for slot, i, item in preamble.items()
412
+ if isinstance(item, PdfInfo) and not any(isinstance(x, RequireMetadata) for x in preamble.before_class)
413
+ ]
414
+ problems += [
415
+ Violation("TM-C006", f"options[{i}]", f"class option {o!r} must match [A-Za-z0-9][A-Za-z0-9=.-]*")
416
+ for i, o in enumerate(options)
417
+ if not _OPTION.fullmatch(o)
418
+ ]
419
+ if problems:
420
+ raise ExceptionGroup("invalid document", [v.error() for v in problems])
421
+ opts = f"[{','.join(options)}]" if options else ""
422
+
423
+ def slot(name: str) -> list[Line]:
424
+ return [
425
+ Line(f"preamble.{name}[{i}]", tex)
426
+ for i, raw in enumerate(getattr(preamble, name))
427
+ for tex in tex_of(raw).tex.splitlines()
428
+ ]
429
+
430
+ def values(text: bool) -> list[Line]:
431
+ """Text settings go before `\\begin{document}`, where a package reads them; the others go in the body."""
432
+ return [
433
+ Line(f"settings[{i}]", setting(type(v).__tm_prefix__, type(v).__name__, v.value))
434
+ for i, v in enumerate(settings)
435
+ if isinstance(v.value, str | Raw) == text
436
+ ]
437
+
438
+ return Plan(
439
+ (
440
+ Line("header", f"% {self.name} document -- {stamp(generator_version())}. Do not edit."),
441
+ *slot("before_class"),
442
+ Line("document", f"\\documentclass{opts}{{{self.document_class}}}"),
443
+ *slot("before_package"),
444
+ *([] if self.builtin else [Line("document", f"\\usepackage{{{self.name}}}")]),
445
+ *values(text=True),
446
+ *slot("after_package"),
447
+ Line("document", "\\begin{document}"),
448
+ *values(text=False),
449
+ *(line for i, node in enumerate(body) for line in node_lines(node, f"{type(node).__name__}[{i}]")),
450
+ Line("document", "\\end{document}"),
451
+ )
452
+ )
453
+
454
+ def document(
455
+ self,
456
+ *body: Node,
457
+ settings: Sequence[AnyValue] = (),
458
+ options: Sequence[str] = (),
459
+ preamble: PreambleSlots | None = None,
460
+ ) -> str:
461
+ """The `.tex` text of `plan(...)`."""
462
+ return self.plan(*body, settings=settings, options=options, preamble=preamble).text
463
+
464
+ def test_document(
465
+ self,
466
+ *body: Node,
467
+ stub: Sequence[Component] = (),
468
+ settings: Sequence[AnyValue] = (),
469
+ options: Sequence[str] = (),
470
+ preamble: PreambleSlots | None = None,
471
+ ) -> str:
472
+ """A `.tex` for testing `body` against stubs (§21a 20): the real packages are loaded, then each class in `stub`
473
+ is replaced by a macro that writes `texmodel:probe:callN=...` to the log instead of typesetting. Read the calls
474
+ with `texmodel_build.testkit.run_probes` and `testing.Call.parse`; `testing.expand` gives the same calls
475
+ without TeX."""
476
+ slots = preamble or PreambleSlots()
477
+ stubs = dataclasses.replace(slots, after_package=(*slots.after_package, Raw(stub_tex(stub))))
478
+ return self.document(*body, settings=settings, options=options, preamble=stubs)
479
+
480
+ def files(self, *, version: str | None = None) -> dict[str, str]:
481
+ """Every generated file, by path relative to the output directory; an adopted package generates none."""
482
+ return {} if self.adopted else {f"{self.name}.sty": self.render(version=version)}