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