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,42 @@
1
+ """The hermetic environment a compile runs in (C3): only what TeX needs, nothing inherited by accident."""
2
+
3
+ import os
4
+ from collections.abc import Sequence
5
+
6
+
7
+ def build_env(
8
+ *,
9
+ roots: Sequence[str],
10
+ fonts: Sequence[str] = (),
11
+ epoch: int | None = None,
12
+ cache: str | None = None,
13
+ inherit: Sequence[str] = (),
14
+ ) -> dict[str, str]:
15
+ """`roots` are paths as the compiler sees them (container paths in a container).
16
+
17
+ A trailing separator in the search variables keeps the default search path after the roots.
18
+ """
19
+ search = os.pathsep.join([".", *roots]) + os.pathsep
20
+ rooted = os.pathsep.join(roots) + os.pathsep if roots else ""
21
+ env = {
22
+ "TEXINPUTS": search,
23
+ "OPENTYPEFONTS": rooted,
24
+ "TTFONTS": rooted,
25
+ # Logs are parsed, so never hard-wrapped.
26
+ "max_print_line": "10000",
27
+ "error_line": "254",
28
+ "half_error_line": "238",
29
+ }
30
+ if rooted == "":
31
+ del env["OPENTYPEFONTS"], env["TTFONTS"]
32
+ if fonts:
33
+ env["OSFONTDIR"] = os.pathsep.join(fonts) # families a document names but never paths
34
+ if cache:
35
+ env["TEXMFVAR"] = env["TEXMFCACHE"] = cache
36
+ if epoch is not None:
37
+ env["SOURCE_DATE_EPOCH"] = str(epoch)
38
+ env["FORCE_SOURCE_DATE"] = "1"
39
+ for name in inherit:
40
+ if name in os.environ:
41
+ env[name] = os.environ[name]
42
+ return env
@@ -0,0 +1,170 @@
1
+ """Parse a LuaTeX/LaTeX log into typed diagnostics (C2).
2
+
3
+ The compile driver runs with `max_print_line` raised, so log lines are never hard-wrapped, and with
4
+ `-file-line-error`, so most errors read `file:line: message`; a few (a missing file) still read `! message`.
5
+ """
6
+
7
+ import dataclasses
8
+ import re
9
+ from collections.abc import Callable, Sequence
10
+
11
+ from ..diagnostics import (
12
+ Diagnostic,
13
+ DocumentRefusal,
14
+ Measure,
15
+ Measurement,
16
+ MissingCharacter,
17
+ MissingFile,
18
+ OverfullBox,
19
+ PackageError,
20
+ PackageWarning,
21
+ RerunNeeded,
22
+ TexError,
23
+ UndefinedControlSequence,
24
+ UndefinedReference,
25
+ overset,
26
+ )
27
+
28
+ _LOC = re.compile(r"^(?P<file>[^\s:][^:]*):(?P<line>\d+): (?P<msg>.*)$")
29
+ _BANG = re.compile(r"^! (?P<msg>.*)$")
30
+ _CONTEXT = re.compile(r"^l\.(?P<line>\d+) (?P<text>.*)$")
31
+ _CS = re.compile(r"(\\[A-Za-z@]+|\\.)\s*$")
32
+ _MISSING_CHAR = re.compile(r"^Missing character: There is no .+ \(U\+(?P<cp>[0-9A-Fa-f]+)\) in font (?P<font>.+)!$")
33
+ _OVERFULL = re.compile(r"^Overfull \\(?P<dir>[hv])box \((?P<amount>[\d.]+)pt too (?:wide|high)\)(?P<rest>.*)$")
34
+ _FIRST_LINE = re.compile(r"lines? (\d+)")
35
+ _RERUN = re.compile(r"Rerun to get|Rerun LaTeX|Please \(re\)run|rerunfilecheck Warning: File .* has changed")
36
+ _UNDEFINED_REF = re.compile(
37
+ r"LaTeX Warning: (?P<kind>Reference|Citation) `(?P<name>.+?)' on page \d+ undefined on input line (?P<line>\d+)"
38
+ )
39
+ _MISSING_FILE = re.compile(r"File `(?P<name>.+?)' not found")
40
+ _PACKAGE_ERROR = re.compile(r"Package (?P<package>\S+) Error:\s*(?P<rest>.*)$")
41
+ _PACKAGE_WARNING = re.compile(r"^Package (?P<package>\S+) Warning:\s*(?P<rest>.*)$")
42
+ _ON_LINE = re.compile(r"\s*on input line (?P<line>\d+)\.?$")
43
+ _PROBE = re.compile(r"^texmodel:probe:(?P<name>[^=\s]+)=(?P<value>.*)$")
44
+ _FIT_PROBE = re.compile(r"^fit\.(?P<item>.+)$")
45
+ _NEEDS_HAS = re.compile(r"^(?P<needs>[^/]+)/(?P<has>[^/]+)$")
46
+ _REFUSE = re.compile(r"^texmodel:refuse:(?P<reason>.*)$")
47
+ _PAGES = re.compile(r"Output written on .* \((?P<n>\d+) pages?, \d+ bytes\)")
48
+ # Consequences of an error already reported, not errors of their own.
49
+ _CONSEQUENCE = ("Emergency stop.", "==> Fatal error occurred")
50
+ _LOOKAHEAD = 15
51
+
52
+
53
+ def parse_log(text: str, measures: Sequence[Measure] = ()) -> tuple[Diagnostic, ...]:
54
+ """Typed diagnostics; `measures` read a package's warnings as `Overset` (B2) besides the warning itself."""
55
+ lines = text.splitlines()
56
+ found: dict[tuple[object, ...], Diagnostic] = {}
57
+
58
+ def add(diagnostic: Diagnostic) -> None:
59
+ # The same finding repeats per pass and per occurrence; keep one with a count.
60
+ key = tuple(v for k, v in sorted(dataclasses.asdict(diagnostic).items()) if k != "count") + (type(diagnostic),)
61
+ seen = found.get(key)
62
+ found[key] = dataclasses.replace(seen, count=seen.count + 1) if seen else diagnostic
63
+
64
+ for i, line in enumerate(lines):
65
+ if m := _LOC.match(line):
66
+ _error(lines, i, m["msg"], m["file"], int(m["line"]), add)
67
+ elif m := _BANG.match(line):
68
+ _error(lines, i, m["msg"], None, _context(lines, i)[0], add)
69
+ else:
70
+ _note(lines, i, add)
71
+ for found_one in list(found.values()):
72
+ if isinstance(found_one, PackageWarning):
73
+ for rule in measures:
74
+ if rule.package == found_one.package and (m := rule.pattern.search(found_one.message)):
75
+ if over := overset(m["item"], m["needs"], m["has"], line=found_one.line):
76
+ add(over)
77
+ return tuple(found.values())
78
+
79
+
80
+ def _note(lines: list[str], i: int, add: Callable[[Diagnostic], None]) -> None:
81
+ """Lines that carry a finding without being an error message: missing glyphs, boxes, references, probes."""
82
+ line = lines[i]
83
+ if m := _MISSING_CHAR.match(line):
84
+ font = m["font"].strip('"')
85
+ cp = m["cp"].upper()
86
+ add(MissingCharacter(message=f"no glyph for U+{cp} in {font}", font=font, codepoint=int(cp, 16)))
87
+ elif m := _OVERFULL.match(line):
88
+ first = _FIRST_LINE.search(m["rest"])
89
+ add(
90
+ OverfullBox(
91
+ message=line, amount_pt=float(m["amount"]), direction=m["dir"], line=int(first[1]) if first else None
92
+ )
93
+ )
94
+ elif m := _UNDEFINED_REF.search(line):
95
+ message = f"{m['kind']} `{m['name']}' undefined"
96
+ add(UndefinedReference(message=message, kind=m["kind"], name=m["name"], line=int(m["line"])))
97
+ elif _RERUN.search(line):
98
+ add(RerunNeeded(message="rerun needed"))
99
+ elif m := _PROBE.match(line):
100
+ _probe(m, add)
101
+ elif m := _REFUSE.match(line):
102
+ add(DocumentRefusal(message=m["reason"]))
103
+ elif m := _PACKAGE_WARNING.match(line):
104
+ package = m["package"]
105
+ detail = [m["rest"].strip()]
106
+ for following in lines[i + 1 : i + 1 + _LOOKAHEAD]:
107
+ if not following.startswith(f"({package})"):
108
+ break
109
+ detail.append(following[len(package) + 2 :].strip())
110
+ message = " ".join(d for d in detail if d)
111
+ where = _ON_LINE.search(message)
112
+ add(
113
+ PackageWarning(
114
+ message=_ON_LINE.sub("", message), package=package, line=int(where["line"]) if where else None
115
+ )
116
+ )
117
+
118
+
119
+ def _probe(m: re.Match[str], add: Callable[[Diagnostic], None]) -> None:
120
+ """A probe is a `Measurement`; `fit.<item>=<needs>/<has>` that does not fit is also an `Overset` (B2)."""
121
+ add(Measurement(message=m[0], name=m["name"], value=m["value"]))
122
+ if (item := _FIT_PROBE.match(m["name"])) and (fit := _NEEDS_HAS.match(m["value"])):
123
+ if over := overset(item["item"], fit["needs"], fit["has"]):
124
+ add(over)
125
+
126
+
127
+ def page_count(text: str) -> int | None:
128
+ m = _PAGES.search(text)
129
+ return int(m["n"]) if m else None
130
+
131
+
132
+ def _context(lines: list[str], i: int) -> tuple[int | None, str]:
133
+ """The `l.N <text>` line TeX prints after an error: the line number and the text up to the break."""
134
+ for line in lines[i + 1 : i + 1 + _LOOKAHEAD]:
135
+ if m := _CONTEXT.match(line):
136
+ return int(m["line"]), m["text"]
137
+ return None, ""
138
+
139
+
140
+ def _error(
141
+ lines: list[str], i: int, msg: str, file: str | None, line: int | None, add: Callable[[Diagnostic], None]
142
+ ) -> None:
143
+ msg = msg.strip()
144
+ if msg.startswith(_CONSEQUENCE):
145
+ return
146
+ if msg.startswith("Undefined control sequence"):
147
+ top = lines[i + 1] if i + 1 < len(lines) else ""
148
+ cs = _CS.search(
149
+ top if top and not _CONTEXT.match(top) else _context(lines, i)[1]
150
+ ) # `<argument> \name`, else `l.N`
151
+ add(
152
+ UndefinedControlSequence(
153
+ message=f"undefined control sequence {cs[1] if cs else '?'}",
154
+ name=cs[1] if cs else "",
155
+ file=file,
156
+ line=line,
157
+ )
158
+ )
159
+ elif m := _MISSING_FILE.search(msg):
160
+ add(MissingFile(message=f"file `{m['name']}' not found", name=m["name"], file=file, line=line))
161
+ elif m := _PACKAGE_ERROR.search(msg):
162
+ package = m["package"]
163
+ detail = [m["rest"].strip()]
164
+ for following in lines[i + 1 : i + 1 + _LOOKAHEAD]:
165
+ if not following.startswith(f"({package})"):
166
+ break
167
+ detail.append(following[len(package) + 2 :].strip())
168
+ add(PackageError(message=" ".join(d for d in detail if d), package=package, file=file, line=line))
169
+ else:
170
+ add(TexError(message=msg, file=file, line=line))
@@ -0,0 +1,52 @@
1
+ """The built-in verifier over proof-pdf (the `proof-pdf` extra): PDF/A, PDF/X-4, geometry, colour, proofing."""
2
+
3
+ import asyncio
4
+ import pathlib
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any
7
+
8
+ from ..diagnostics import Severity
9
+ from ..verify import Finding
10
+
11
+
12
+ def _profile(options: Mapping[str, Any]) -> Any:
13
+ from proof_pdf.models import PageGeometry, Profile
14
+
15
+ opts = dict(options)
16
+ if "geometry" in opts:
17
+ opts["geometry"] = PageGeometry(**opts["geometry"])
18
+ if "proof_profile" in opts:
19
+ opts["proof_profile"] = pathlib.Path(opts["proof_profile"])
20
+ if "allow_spots" in opts:
21
+ opts["allow_spots"] = tuple(opts["allow_spots"])
22
+ return Profile(**opts)
23
+
24
+
25
+ class ProofPdf:
26
+ """Options are proof_pdf.models.Profile's fields (`geometry` as a table); a file is held to exactly that profile."""
27
+
28
+ name = "proof-pdf"
29
+
30
+ def unavailable(self, options: Mapping[str, Any]) -> str | None:
31
+ try:
32
+ from proof_pdf import core
33
+ except ImportError:
34
+ return "proof-pdf is not installed (pip install 'texmodel[proof-pdf]')"
35
+ try:
36
+ profile = _profile(options)
37
+ except (TypeError, ValueError) as exc:
38
+ return f"bad proof-pdf options: {exc}"
39
+ missing = core.missing_tools(profile)
40
+ return f"missing tools: {', '.join(missing)}" if missing else None
41
+
42
+ def verify(self, path: pathlib.Path, options: Mapping[str, Any]) -> Sequence[Finding]:
43
+ from proof_pdf import core, models
44
+
45
+ # ponytail: asyncio.run per file, so not callable inside a running loop; fine for the CLI and threads.
46
+ report = asyncio.run(core.verify_file(path, _profile(options)))
47
+ return [
48
+ Finding(
49
+ f.check, Severity.ERROR if f.severity is models.Severity.ERROR else Severity.WARNING, f.message, f.page
50
+ )
51
+ for f in report.findings
52
+ ]
@@ -0,0 +1,36 @@
1
+ """The one place a process starts (C3): argv lists only, never a shell string.
2
+
3
+ import-linter forbids importing `subprocess` anywhere else.
4
+ """
5
+
6
+ import dataclasses
7
+ import pathlib
8
+ import subprocess
9
+ from collections.abc import Sequence
10
+
11
+ from ..errors import ToolchainError
12
+
13
+
14
+ @dataclasses.dataclass(frozen=True)
15
+ class Proc:
16
+ returncode: int
17
+ stdout: str
18
+ stderr: str
19
+ timed_out: bool = False
20
+
21
+
22
+ def text(data: str | bytes | None) -> str:
23
+ return data.decode(errors="replace") if isinstance(data, bytes) else (data or "")
24
+
25
+
26
+ def spawn(argv: Sequence[str], *, cwd: pathlib.Path | None, env: dict[str, str], timeout: float | None) -> Proc:
27
+ """The one place a process starts."""
28
+ try:
29
+ done = subprocess.run( # noqa: S603
30
+ list(argv), cwd=cwd, env=env, capture_output=True, text=True, errors="replace", timeout=timeout, check=False
31
+ )
32
+ except subprocess.TimeoutExpired as exc:
33
+ return Proc(-1, text(exc.stdout), text(exc.stderr), timed_out=True)
34
+ except FileNotFoundError as exc:
35
+ raise ToolchainError(f"{argv[0]}: not found", code="TM-T001", hint="run `texmodel-build doctor`") from exc
36
+ return Proc(done.returncode, done.stdout, done.stderr)
@@ -0,0 +1,37 @@
1
+ """What a `.sty` or `.cls` file asks TeX to load (D2): the files `\\RequirePackage` and its kin name.
2
+
3
+ A scan, not an expansion: a name built from a macro (`\\RequirePackage{\\pkg}`) cannot be known and is reported as
4
+ unchecked, never guessed.
5
+ """
6
+
7
+ import re
8
+ from collections.abc import Iterable
9
+
10
+ _COMMENT = re.compile(r"(?<!\\)%.*")
11
+ _LOAD = re.compile(
12
+ r"\\(?P<cmd>RequirePackage(?:WithOptions)?|usepackage|LoadClass(?:WithOptions)?|tcbuselibrary)"
13
+ r"\s*(?:\[[^\]]*\])?\s*\{(?P<names>[^}]*)\}"
14
+ )
15
+ SAFE_NAME = re.compile(r"[A-Za-z0-9._-]+") # also the only names the toolchain probe will take on a shell line
16
+
17
+
18
+ def _file(cmd: str, name: str) -> str:
19
+ if cmd == "tcbuselibrary":
20
+ return f"tcb{name}.code.tex"
21
+ return f"{name}.cls" if cmd.startswith("LoadClass") else f"{name}.sty"
22
+
23
+
24
+ def needed(texts: Iterable[str]) -> tuple[list[str], list[str]]:
25
+ """The files named in `texts`, in order and once, and the names that are not plain file names (unchecked)."""
26
+ files: dict[str, None] = {}
27
+ unchecked: dict[str, None] = {}
28
+ for text in texts:
29
+ for m in _LOAD.finditer(_COMMENT.sub("", text)):
30
+ for name in (n.strip() for n in m["names"].split(",")):
31
+ if not name:
32
+ continue
33
+ if SAFE_NAME.fullmatch(name):
34
+ files[_file(m["cmd"], name)] = None
35
+ else:
36
+ unchecked[name] = None
37
+ return list(files), list(unchecked)