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
@@ -0,0 +1,65 @@
1
+ """`CompileResult` (C1): a failed compile is a result, not an exception (E3)."""
2
+
3
+ import dataclasses
4
+ import pathlib
5
+ from typing import Any, Self
6
+
7
+ from .diagnostics import Diagnostic, Measurement, Severity, diagnostic_from_dict
8
+ from .errors import CompileError
9
+
10
+
11
+ @dataclasses.dataclass(frozen=True, kw_only=True)
12
+ class CompileResult:
13
+ pdf: pathlib.Path | None
14
+ log: pathlib.Path | None
15
+ passes: int
16
+ pages: int | None
17
+ diagnostics: tuple[Diagnostic, ...]
18
+ seconds: float
19
+ engine: str
20
+ engine_version: str
21
+ latexmk_version: str
22
+ image: str | None = None # None: ran on the host, not in the reference image
23
+ image_digest: str | None = None
24
+ image_revision: str | None = None
25
+
26
+ @property
27
+ def ok(self) -> bool:
28
+ return self.pdf is not None and not self.errors
29
+
30
+ @property
31
+ def errors(self) -> tuple[Diagnostic, ...]:
32
+ return tuple(d for d in self.diagnostics if d.severity is Severity.ERROR)
33
+
34
+ @property
35
+ def probes(self) -> dict[str, str]:
36
+ return {d.name: d.value for d in self.diagnostics if isinstance(d, Measurement)}
37
+
38
+ def probe(self, name: str) -> str:
39
+ return self.probes[name]
40
+
41
+ def raise_for_status(self) -> None:
42
+ if self.ok:
43
+ return
44
+ first = self.errors[0] if self.errors else None
45
+ raise CompileError(
46
+ f"compile failed: {len(self.errors)} error(s)"
47
+ + (f", first: {first.code} {first.message}" if first else ""),
48
+ code=first.code if first else "TM-X099",
49
+ diagnostics=self.diagnostics,
50
+ )
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ data = dataclasses.asdict(self)
54
+ data["pdf"] = str(self.pdf) if self.pdf else None
55
+ data["log"] = str(self.log) if self.log else None
56
+ data["diagnostics"] = [d.to_dict() for d in self.diagnostics]
57
+ return data
58
+
59
+ @classmethod
60
+ def from_dict(cls, data: dict[str, Any], **override: Any) -> Self:
61
+ data = {**data, **override}
62
+ data["pdf"] = pathlib.Path(data["pdf"]) if data["pdf"] else None
63
+ data["log"] = pathlib.Path(data["log"]) if data["log"] else None
64
+ data["diagnostics"] = tuple(diagnostic_from_dict(d) for d in data["diagnostics"])
65
+ return cls(**data)
@@ -0,0 +1,41 @@
1
+ """Ordered asset roots (C4): the first root holding a name wins, and the rest are reported as shadowed."""
2
+
3
+ import dataclasses
4
+ import logging
5
+ import pathlib
6
+ from collections.abc import Sequence
7
+
8
+ from texmodel.errors import ValidationError
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @dataclasses.dataclass(frozen=True)
14
+ class Resolution:
15
+ name: str
16
+ path: pathlib.Path
17
+ shadowed: tuple[pathlib.Path, ...] = ()
18
+
19
+
20
+ def resolve(name: str, roots: Sequence[pathlib.Path]) -> Resolution | None:
21
+ """Find `name` under `roots`; the winner is the earliest root. Absolute names and `..` are refused (Z1)."""
22
+ rel = pathlib.PurePosixPath(name)
23
+ if rel.is_absolute() or ".." in rel.parts:
24
+ raise ValidationError(
25
+ f"asset name {name!r} escapes the asset roots", code="TM-C100", hint="use a relative name"
26
+ )
27
+ hits = [root / name for root in roots if (root / name).is_file()]
28
+ if not hits:
29
+ return None
30
+ if len(hits) > 1:
31
+ logger.warning("asset %s exists in %d roots; %s wins over %s", name, len(hits), hits[0], hits[1:])
32
+ return Resolution(name, hits[0], tuple(hits[1:]))
33
+
34
+
35
+ def shadowing(roots: Sequence[pathlib.Path]) -> list[Resolution]:
36
+ """Every name present in more than one root, with the winner and what it hides."""
37
+ seen: dict[str, list[pathlib.Path]] = {}
38
+ for root in roots:
39
+ for path in sorted(p for p in root.rglob("*") if p.is_file()):
40
+ seen.setdefault(path.relative_to(root).as_posix(), []).append(path)
41
+ return [Resolution(name, paths[0], tuple(paths[1:])) for name, paths in sorted(seen.items()) if len(paths) > 1]
@@ -0,0 +1,43 @@
1
+ """Build every declared target (C10): reruns until stable are the compiler's, this adds parallelism and one summary."""
2
+
3
+ import dataclasses
4
+ import pathlib
5
+ from collections.abc import Iterable
6
+ from concurrent.futures import ThreadPoolExecutor
7
+
8
+ from texmodel.pointer import load_plan
9
+
10
+ from .compiler import Compiler
11
+ from .config import Target
12
+ from .engines import get_engine
13
+ from .result import CompileResult
14
+
15
+
16
+ @dataclasses.dataclass(frozen=True)
17
+ class BuildSummary:
18
+ results: dict[str, CompileResult]
19
+
20
+ @property
21
+ def ok(self) -> bool:
22
+ return all(r.ok for r in self.results.values())
23
+
24
+ def lines(self) -> list[str]:
25
+ return [
26
+ f"{'ok ' if r.ok else 'FAILED'} {name}: {len(r.errors)} error(s), {r.pages} page(s), {r.seconds}s"
27
+ for name, r in self.results.items()
28
+ ]
29
+
30
+
31
+ def build_targets(
32
+ targets: Iterable[Target], compiler: Compiler, *, root: pathlib.Path, outdir: pathlib.Path, jobs: int = 1
33
+ ) -> BuildSummary:
34
+ """Compile each target into `outdir/<name>`; the engine comes from the target. Results keep declared order."""
35
+ chosen = list(targets)
36
+
37
+ def one(target: Target) -> CompileResult:
38
+ mine = dataclasses.replace(compiler, engine=get_engine(target.engine))
39
+ plan = load_plan(target.plan, root) if target.plan else None
40
+ return mine.compile(root / target.source, outdir / target.name, plan=plan)
41
+
42
+ with ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
43
+ return BuildSummary({t.name: r for t, r in zip(chosen, pool.map(one, chosen), strict=True)})
@@ -0,0 +1,59 @@
1
+ """Contract checks a verifier plugin must pass (L1). Plain asserts, so any test runner can call them."""
2
+
3
+ import pathlib
4
+ import tempfile
5
+ from collections.abc import Mapping
6
+
7
+ from texmodel.errors import Violation
8
+ from texmodel.package import Package, PreambleSlots
9
+ from texmodel.plan import Plan
10
+ from texmodel.testing import check_exports, export_probes
11
+ from texmodel.types import Raw
12
+
13
+ from .compiler import Compiler
14
+ from .diagnostics import Measurement
15
+ from .verify import Profile, Verifier, verify_file
16
+
17
+
18
+ def run_probes(
19
+ compiler: Compiler, tex: str, files: Mapping[str, str] | None = None, *, plans: Mapping[str, Plan] | None = None
20
+ ) -> list[Measurement]:
21
+ """Compile `tex` and return its `texmodel:probe:` lines in log order (§21a 20, C5a). `files` are written beside it
22
+ first (path -> text: the `.sty` the document loads); the build directory is removed afterwards. A document that
23
+ does not compile raises `CompileError`, so a test never reads probes from a broken run; its diagnostics carry the
24
+ `path` from `plans` (file name -> the plan that wrote it)."""
25
+ with tempfile.TemporaryDirectory() as tmp:
26
+ work = pathlib.Path(tmp).resolve() / "src"
27
+ for rel, text in {**(files or {}), "probe.tex": tex}.items():
28
+ path = work / rel
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ path.write_text(text, encoding="utf-8")
31
+ result = compiler.compile(work / "probe.tex", pathlib.Path(tmp).resolve() / "out", plans=plans)
32
+ result.raise_for_status()
33
+ return [d for d in result.diagnostics if isinstance(d, Measurement)]
34
+
35
+
36
+ def check_library(compiler: Compiler, package: Package, files: Mapping[str, str] | None = None) -> list[Violation]:
37
+ """Compile `package` and check that every component a library block defines exists with the declared arguments
38
+ (§21a 12): `Package.test_document` with `export_probes`, run with `run_probes`, read by `check_exports`. A block
39
+ TeX refuses raises `CompileError` whose diagnostics name the block as their `path`. `files` are other files the
40
+ package loads."""
41
+ tex = package.test_document(preamble=PreambleSlots(after_package=(Raw(export_probes(package)),)))
42
+ sty = f"{package.name}.sty"
43
+ found = run_probes(compiler, tex, {sty: package.render(), **(files or {})}, plans={sty: package.render_plan()})
44
+ return check_exports(package, {m.name: m.value for m in found})
45
+
46
+
47
+ def check_verifier_contract(verifier: Verifier, tmp_path: pathlib.Path) -> None:
48
+ """A verifier names itself, and a missing file or an unavailable tool fails; neither passes nor raises."""
49
+ assert isinstance(verifier.name, str) and verifier.name, "a verifier needs a non-empty name"
50
+ available = {verifier.name: verifier}
51
+ profile = Profile(checks=(verifier.name,))
52
+ report = verify_file(tmp_path / "does-not-exist", profile, available)
53
+ assert not report.ok, "a missing file must fail, not pass"
54
+ why = verifier.unavailable({})
55
+ assert why is None or isinstance(why, str), "unavailable() returns a reason or None"
56
+ if why is not None:
57
+ present = tmp_path / "present"
58
+ present.write_bytes(b"x")
59
+ assert not verify_file(present, profile, available).ok, "an unavailable verifier must fail the file"
@@ -0,0 +1,158 @@
1
+ """Verifier plugins (V4, V5, L1): checks over a built file, discovered through entry points.
2
+
3
+ A verifier is any object with a `name`, an `unavailable(options)` that says why it cannot run here (or `None`), and a
4
+ `verify(path, options)` returning findings. Register it in the `texmodel_build.verifiers` entry-point group, as a
5
+ callable that returns the verifier.
6
+
7
+ The rule that matters is V5, **unconfirmable means failure**: a verifier that cannot run, a file that cannot be opened,
8
+ a plugin that crashes, and a profile that selects nothing are all errors, never skips. The only way to excuse a file is
9
+ the exemption registry, which keeps it listed with its reason.
10
+ """
11
+
12
+ import dataclasses
13
+ import fnmatch
14
+ import importlib.metadata
15
+ import pathlib
16
+ from collections.abc import Mapping, Sequence
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from typing import Any, Protocol
19
+
20
+ from .diagnostics import Severity
21
+ from .errors import ToolchainError
22
+
23
+ GROUP = "texmodel_build.verifiers"
24
+
25
+
26
+ @dataclasses.dataclass(frozen=True)
27
+ class Finding:
28
+ """One issue a check raised, optionally tied to a 1-based page."""
29
+
30
+ check: str
31
+ severity: Severity
32
+ message: str
33
+ page: int | None = None
34
+
35
+
36
+ @dataclasses.dataclass(frozen=True, kw_only=True)
37
+ class Profile:
38
+ """Which verifiers to run (empty: every registered one) and their options, keyed by verifier name."""
39
+
40
+ name: str = "default"
41
+ checks: tuple[str, ...] = ()
42
+ options: Mapping[str, Mapping[str, Any]] = dataclasses.field(default_factory=dict[str, Mapping[str, Any]])
43
+
44
+
45
+ @dataclasses.dataclass(frozen=True)
46
+ class FileReport:
47
+ path: pathlib.Path
48
+ findings: tuple[Finding, ...] = ()
49
+ exempt: str | None = None # why the file was declared out of scope; listed, never a failure
50
+
51
+ @property
52
+ def errors(self) -> tuple[Finding, ...]:
53
+ return tuple(f for f in self.findings if f.severity is Severity.ERROR)
54
+
55
+ @property
56
+ def ok(self) -> bool:
57
+ return not self.errors
58
+
59
+
60
+ class Verifier(Protocol):
61
+ name: str
62
+
63
+ def unavailable(self, options: Mapping[str, Any]) -> str | None:
64
+ """Why this verifier cannot run here with these options (a missing tool, say), or `None`."""
65
+ ...
66
+
67
+ def verify(self, path: pathlib.Path, options: Mapping[str, Any]) -> Sequence[Finding]: ...
68
+
69
+
70
+ def registered() -> dict[str, Verifier]:
71
+ """Every verifier in the entry-point group. A plugin that fails to load is an error, not a silent omission."""
72
+ found: dict[str, Verifier] = {}
73
+ for entry in importlib.metadata.entry_points(group=GROUP):
74
+ try:
75
+ verifier: Verifier = entry.load()()
76
+ except Exception as exc:
77
+ raise ToolchainError(
78
+ f"verifier plugin {entry.name!r} failed to load: {exc!r}", code="TM-V001", hint="reinstall the plugin"
79
+ ) from exc
80
+ found[verifier.name] = verifier
81
+ return found
82
+
83
+
84
+ def exemption(path: pathlib.PurePath, exempt: Mapping[str, str]) -> str | None:
85
+ """The reason `path` is exempt: a glob matching the path or any trailing part of it (`res/**`, `*_preview.pdf`)."""
86
+ parts = path.as_posix().split("/")
87
+ tails = ["/".join(parts[i:]) for i in range(len(parts))]
88
+ for glob, reason in exempt.items():
89
+ if any(fnmatch.fnmatch(tail, glob) for tail in tails):
90
+ return reason
91
+ return None
92
+
93
+
94
+ def verify_file(
95
+ path: pathlib.Path,
96
+ profile: Profile,
97
+ available: Mapping[str, Verifier],
98
+ exempt: Mapping[str, str] | None = None,
99
+ ) -> FileReport:
100
+ """Run the profile's verifiers over one file. Reports; never raises for a bad file or a bad plugin."""
101
+ if reason := exemption(path, exempt or {}):
102
+ return FileReport(path, exempt=reason)
103
+ names = profile.checks or tuple(available)
104
+ if not names:
105
+ return FileReport(
106
+ path, (Finding("verify", Severity.ERROR, "no verifier selected or installed: nothing was checked"),)
107
+ )
108
+ if not path.is_file():
109
+ return FileReport(path, (Finding("open", Severity.ERROR, "no such file"),))
110
+ findings: list[Finding] = []
111
+ for name in names:
112
+ verifier = available.get(name)
113
+ if verifier is None:
114
+ findings.append(Finding(name, Severity.ERROR, f"unknown verifier; installed: {sorted(available)}"))
115
+ continue
116
+ options = profile.options.get(name, {})
117
+ if why := verifier.unavailable(options):
118
+ findings.append(Finding(name, Severity.ERROR, f"cannot confirm: {why}"))
119
+ continue
120
+ try:
121
+ findings.extend(verifier.verify(path, options))
122
+ except Exception as exc:
123
+ findings.append(Finding(name, Severity.ERROR, f"verifier crashed: {exc!r}"))
124
+ return FileReport(path, tuple(findings))
125
+
126
+
127
+ def verify_files(
128
+ paths: Sequence[pathlib.Path],
129
+ profile: Profile,
130
+ available: Mapping[str, Verifier],
131
+ exempt: Mapping[str, str] | None = None,
132
+ *,
133
+ jobs: int = 1,
134
+ ) -> list[FileReport]:
135
+ """One report per path, in order."""
136
+
137
+ def one(path: pathlib.Path) -> FileReport:
138
+ return verify_file(path, profile, available, exempt)
139
+
140
+ with ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
141
+ return list(pool.map(one, paths))
142
+
143
+
144
+ def expand_targets(
145
+ paths: Sequence[pathlib.Path], exclude: Sequence[str] = (), suffix: str = ".pdf"
146
+ ) -> list[pathlib.Path]:
147
+ """Files as given; a directory expands to every `suffix` file beneath it minus `exclude` globs (relative to it)."""
148
+ out: list[pathlib.Path] = []
149
+ for path in paths:
150
+ if path.is_dir():
151
+ out += [
152
+ p
153
+ for p in sorted(path.rglob(f"*{suffix}"))
154
+ if exemption(p.relative_to(path), dict.fromkeys(exclude, "")) is None
155
+ ]
156
+ else:
157
+ out.append(path)
158
+ return out