machinechecked 0.1.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 (58) hide show
  1. machinechecked-0.1.0.dist-info/METADATA +108 -0
  2. machinechecked-0.1.0.dist-info/RECORD +58 -0
  3. machinechecked-0.1.0.dist-info/WHEEL +5 -0
  4. machinechecked-0.1.0.dist-info/entry_points.txt +3 -0
  5. machinechecked-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. machinechecked-0.1.0.dist-info/top_level.txt +1 -0
  7. prooflens/__init__.py +1 -0
  8. prooflens/__main__.py +3 -0
  9. prooflens/assess.py +504 -0
  10. prooflens/audit.py +115 -0
  11. prooflens/bundle.py +70 -0
  12. prooflens/cli.py +542 -0
  13. prooflens/config.py +145 -0
  14. prooflens/data/ProofLensProbe.lean +469 -0
  15. prooflens/data/prooflens.sty +75 -0
  16. prooflens/data/schema/evidence.schema.json +24 -0
  17. prooflens/data/schema/inventory.schema.json +55 -0
  18. prooflens/data/schema/manifest-claim.schema.json +126 -0
  19. prooflens/data/schema/manifest-index.schema.json +128 -0
  20. prooflens/evidence.py +60 -0
  21. prooflens/export.py +227 -0
  22. prooflens/formalization.py +106 -0
  23. prooflens/manifest.py +296 -0
  24. prooflens/pdfclaims.py +129 -0
  25. prooflens/pipeline.py +331 -0
  26. prooflens/probe.py +126 -0
  27. prooflens/promote.py +204 -0
  28. prooflens/review_queue.py +138 -0
  29. prooflens/reviews.py +105 -0
  30. prooflens/schemas.py +19 -0
  31. prooflens/server.py +63 -0
  32. prooflens/simple.py +628 -0
  33. prooflens/sources.py +164 -0
  34. prooflens/tex.py +408 -0
  35. prooflens/util.py +66 -0
  36. prooflens/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  37. prooflens/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  38. prooflens/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  39. prooflens/web/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  40. prooflens/web/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  41. prooflens/web/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  42. prooflens/web/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  43. prooflens/web/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  44. prooflens/web/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  45. prooflens/web/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  46. prooflens/web/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  47. prooflens/web/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  48. prooflens/web/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  49. prooflens/web/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  50. prooflens/web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  51. prooflens/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  52. prooflens/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. prooflens/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  54. prooflens/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  55. prooflens/web/assets/index-B2GDDbsW.css +1 -0
  56. prooflens/web/assets/index-DN7uLe7p.js +285 -0
  57. prooflens/web/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  58. prooflens/web/index.html +14 -0
prooflens/sources.py ADDED
@@ -0,0 +1,164 @@
1
+ """Acquire a paper's source at a pinned revision, and decide between LaTeX and PDF.
2
+
3
+ Acquisition is the only stage that may use credentials; it writes a plain snapshot directory and
4
+ nothing it produces is executed. Compilation (`tex.py`) works on a copy of the snapshot.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import tarfile
14
+ import io
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from .config import Config, Paper
19
+
20
+
21
+ class SourceError(RuntimeError):
22
+ pass
23
+
24
+
25
+ @dataclass
26
+ class Snapshot:
27
+ paper: str
28
+ dir: Path
29
+ kind: str # "path" | "git" | "arxiv"
30
+ revision: str | None # commit for git, version for arXiv, None for a working tree
31
+ mode: str # "latex" | "pdf"
32
+ root: str | None # LaTeX root, relative to `dir`
33
+ pdf: str | None # committed or provided PDF, relative to `dir`
34
+ note: str = ""
35
+ commit_time: int | None = None
36
+
37
+
38
+ DOCCLASS = re.compile(r"^[^%\n]*\\documentclass", re.M)
39
+
40
+
41
+ def _latex_roots(d: Path) -> list[str]:
42
+ roots = []
43
+ for p in sorted(d.rglob("*.tex")):
44
+ try:
45
+ if DOCCLASS.search(p.read_text(encoding="utf-8", errors="replace")):
46
+ roots.append(str(p.relative_to(d)))
47
+ except OSError:
48
+ continue
49
+ return roots
50
+
51
+
52
+ def _pdfs(d: Path) -> list[str]:
53
+ return sorted(str(p.relative_to(d)) for p in d.rglob("*.pdf"))
54
+
55
+
56
+ def choose(paper: Paper, d: Path) -> tuple[str, str | None, str | None, str]:
57
+ """LaTeX if a root is found, else the PDF of the same source. Several candidate roots or PDFs
58
+ without an explicit choice is an error, not a guess."""
59
+ pdfs = _pdfs(d)
60
+ pdf = paper.pdf if paper.pdf else None
61
+ if pdf and not (d / pdf).exists():
62
+ raise SourceError(f"paper {paper.id}: pdf {pdf} not in the source")
63
+ if paper.root:
64
+ if not (d / paper.root).exists():
65
+ raise SourceError(f"paper {paper.id}: root {paper.root} not in the source")
66
+ return "latex", paper.root, pdf, ""
67
+ roots = _latex_roots(d)
68
+ if len(roots) == 1:
69
+ return "latex", roots[0], pdf, ""
70
+ if len(roots) > 1:
71
+ raise SourceError(f"paper {paper.id}: several LaTeX roots {roots}; set `root`")
72
+ if pdf:
73
+ return "pdf", None, pdf, "no LaTeX root in the source"
74
+ if len(pdfs) == 1:
75
+ return "pdf", None, pdfs[0], "no LaTeX root in the source"
76
+ if not pdfs:
77
+ raise SourceError(f"paper {paper.id}: neither a LaTeX root nor a PDF in the source")
78
+ raise SourceError(f"paper {paper.id}: no LaTeX root and several PDFs {pdfs}; set `pdf`")
79
+
80
+
81
+ def _git_url(spec: str) -> str:
82
+ if re.fullmatch(r"[\w.-]+/[\w.-]+", spec):
83
+ return f"https://github.com/{spec}.git"
84
+ return spec
85
+
86
+
87
+ def _git_env(paper: Paper) -> dict:
88
+ env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
89
+ tok_var = paper.source.get("token_env")
90
+ if tok_var and os.environ.get(tok_var):
91
+ import base64
92
+ cred = base64.b64encode(f"x-access-token:{os.environ[tok_var]}".encode()).decode()
93
+ env["GIT_CONFIG_COUNT"] = "1"
94
+ env["GIT_CONFIG_KEY_0"] = "http.extraHeader"
95
+ env["GIT_CONFIG_VALUE_0"] = f"Authorization: Basic {cred}"
96
+ return env
97
+
98
+
99
+ def acquire(cfg: Config, paper: Paper, dest: Path) -> Snapshot:
100
+ src = paper.source
101
+ if dest.exists():
102
+ shutil.rmtree(dest)
103
+ dest.mkdir(parents=True)
104
+ if "path" in src:
105
+ base = (cfg.root / src["path"]).resolve()
106
+ if not base.is_dir():
107
+ raise SourceError(f"paper {paper.id}: {base} is not a directory")
108
+ shutil.copytree(base, dest, dirs_exist_ok=True, copy_function=shutil.copy2,
109
+ ignore_dangling_symlinks=True,
110
+ ignore=shutil.ignore_patterns(".git", ".lake", ".machinechecked", ".prooflens",
111
+ "*.aux", "*.log", "*.fls"))
112
+ kind, rev, commit_time = "path", None, None
113
+ elif "git" in src:
114
+ rev = src.get("rev")
115
+ if not rev:
116
+ raise SourceError(f"paper {paper.id}: a git source needs `rev` (a commit)")
117
+ sub = src.get("subdir")
118
+ local = (cfg.root / src["git"]).resolve() if not re.fullmatch(
119
+ r"[\w.-]+/[\w.-]+", src["git"]) and not src["git"].startswith(("http", "git@")) \
120
+ else None
121
+ env = _git_env(paper)
122
+ if local and local.exists():
123
+ repo = local
124
+ else:
125
+ repo = dest.parent / f".{paper.id}-repo"
126
+ if not repo.exists():
127
+ r = subprocess.run(["git", "clone", "--quiet", "--filter=blob:none", "--no-checkout",
128
+ _git_url(src["git"]), str(repo)], env=env,
129
+ capture_output=True, text=True)
130
+ if r.returncode != 0:
131
+ raise SourceError(f"paper {paper.id}: clone failed: {r.stderr.strip()}")
132
+ subprocess.run(["git", "-C", str(repo), "fetch", "--quiet", "origin", rev], env=env,
133
+ capture_output=True)
134
+ full = subprocess.run(["git", "-C", str(repo), "rev-parse", "--verify", f"{rev}^{{commit}}"],
135
+ capture_output=True, text=True)
136
+ if full.returncode != 0:
137
+ raise SourceError(f"paper {paper.id}: revision {rev} not found")
138
+ rev = full.stdout.strip()
139
+ ct = subprocess.run(["git", "-C", str(repo), "show", "-s", "--format=%ct", rev],
140
+ capture_output=True, text=True)
141
+ commit_time = int(ct.stdout.strip()) if ct.returncode == 0 else None
142
+ args = ["git", "-C", str(repo), "archive", "--format=tar", rev] + ([sub] if sub else [])
143
+ arch = subprocess.run(args, capture_output=True)
144
+ if arch.returncode != 0:
145
+ raise SourceError(f"paper {paper.id}: git archive failed: {arch.stderr.decode()}")
146
+ with tarfile.open(fileobj=io.BytesIO(arch.stdout)) as t:
147
+ t.extractall(dest, filter="data")
148
+ kind = "git"
149
+ else:
150
+ raise SourceError(f"paper {paper.id}: arXiv sources are not supported yet")
151
+ mode, root, pdf, note = choose(paper, dest)
152
+ return Snapshot(paper.id, dest, kind, rev, mode, root, pdf, note, commit_time)
153
+
154
+
155
+ def branch_ahead(cfg: Config, paper: Paper, branch: str) -> int | None:
156
+ """How many commits `branch` has beyond the pinned revision (informational drift)."""
157
+ if "git" not in paper.source or not paper.source.get("rev"):
158
+ return None
159
+ local = (cfg.root / paper.source["git"]).resolve()
160
+ if not local.exists():
161
+ return None
162
+ r = subprocess.run(["git", "-C", str(local), "rev-list", "--count",
163
+ f"{paper.source['rev']}..{branch}"], capture_output=True, text=True)
164
+ return int(r.stdout.strip()) if r.returncode == 0 else None
prooflens/tex.py ADDED
@@ -0,0 +1,408 @@
1
+ """Compile a LaTeX snapshot with claim anchors, and read the anchors back.
2
+
3
+ The compilation runs on a private copy of the snapshot, with `latexmk -norc` (no rc files from the
4
+ source), shell escape off, kpathsea's paranoid output policy, and CPU and file-size limits.
5
+ `prooflens.sty` is injected before `\\documentclass`; the source is not edited.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import resource
13
+ import shutil
14
+ import subprocess
15
+ import tempfile
16
+ from dataclasses import dataclass, field
17
+ from importlib import resources
18
+ from pathlib import Path
19
+
20
+ from .sources import Snapshot
21
+ from .util import norm_ws, sha256_file, sha256_text
22
+
23
+ SP_PER_BP = 65781.76 # scaled points per PostScript point (PDF user unit)
24
+ DEFAULT_ENVS = ["theorem", "lemma", "proposition", "corollary", "definition", "assumption",
25
+ "remark", "example", "conjecture", "claim", "hypothesis", "convention"]
26
+
27
+
28
+ class TexError(RuntimeError):
29
+ pass
30
+
31
+
32
+ @dataclass
33
+ class Anchor:
34
+ id: int
35
+ env: str
36
+ file: str
37
+ line: int
38
+ number: str | None = None
39
+ labels: list[tuple[str, str]] = field(default_factory=list)
40
+ begin: tuple[int, float, float] | None = None # (page, x, y) in PDF points, y from top
41
+ end: tuple[int, float, float] | None = None
42
+ head: tuple[int, float, float] | None = None
43
+ statement: str | None = None
44
+ source_sha256: str | None = None
45
+ title: str | None = None # the optional argument of `\\begin{env}[…]`, as TeX
46
+
47
+ @property
48
+ def label(self) -> str | None:
49
+ """The environment's own label: the first whose current label is its number."""
50
+ for name, cur in self.labels:
51
+ if cur == self.number:
52
+ return name
53
+ return None
54
+
55
+ def to_json(self) -> dict:
56
+ return {"id": self.id, "env": self.env, "file": self.file, "line": self.line,
57
+ "number": self.number, "label": self.label,
58
+ "labels": [list(x) for x in self.labels],
59
+ "begin": list(self.begin) if self.begin else None,
60
+ "end": list(self.end) if self.end else None,
61
+ "head": list(self.head) if self.head else None, "title": self.title,
62
+ "source_sha256": self.source_sha256, "statement": self.statement}
63
+
64
+
65
+ def _limits():
66
+ resource.setrlimit(resource.RLIMIT_CPU, (600, 600))
67
+ resource.setrlimit(resource.RLIMIT_FSIZE, (512 << 20, 512 << 20))
68
+
69
+
70
+ def theorem_envs(d: Path) -> list[str]:
71
+ """Environment names declared with `\\newtheorem` anywhere in the source."""
72
+ names = set()
73
+ for p in d.rglob("*.tex"):
74
+ txt = p.read_text(encoding="utf-8", errors="replace")
75
+ names.update(re.findall(r"\\newtheorem\*?\{([A-Za-z@]+)\}", txt))
76
+ return sorted(names)
77
+
78
+
79
+ def source_epoch(snap: Snapshot) -> int:
80
+ """The time the build is dated with, so that the same source gives the same PDF: the
81
+ commit time for a git source, else the latest modification time of the source files."""
82
+ if snap.commit_time is not None:
83
+ return snap.commit_time
84
+ times = [p.stat().st_mtime for p in snap.dir.rglob("*") if p.is_file()]
85
+ return int(max(times)) if times else 0
86
+
87
+
88
+ ENGINE_FLAG = {"pdflatex": "-pdf", "xelatex": "-xelatex", "lualatex": "-lualatex"}
89
+
90
+
91
+ def tex_engine(root: Path) -> str:
92
+ """The TeX engine a LaTeX root needs, read without running anything from the source: a
93
+ `% !TEX program` comment, else the engine a `latexmkrc` beside it names (matched as text,
94
+ never executed), else XeLaTeX if the sources load `fontspec` or `unicode-math`."""
95
+ head = "\n".join(root.read_text(errors="replace").splitlines()[:30])
96
+ m = re.search(r"^\s*%\s*!\s*TEX\s+(?:TS-)?program\s*=\s*(pdflatex|xelatex|lualatex)",
97
+ head, re.M | re.I)
98
+ if m:
99
+ return m.group(1).lower()
100
+ for rc in ("latexmkrc", ".latexmkrc"):
101
+ try:
102
+ t = (root.parent / rc).read_text(errors="replace")
103
+ except OSError:
104
+ continue
105
+ t = "\n".join(l.split("#", 1)[0] for l in t.splitlines())
106
+ m = re.search(r"\$pdflatex\s*=\s*['\"]\s*(xelatex|lualatex)", t)
107
+ if m:
108
+ return m.group(1)
109
+ m = re.search(r"\$pdf_mode\s*=\s*([45])", t)
110
+ if m:
111
+ return "lualatex" if m.group(1) == "4" else "xelatex"
112
+ for f in root.parent.rglob("*.tex"):
113
+ try:
114
+ if re.search(r"^[^%\n]*\\usepackage(\[[^\]]*\])?\{[^}]*\b(fontspec|unicode-math)\b",
115
+ f.read_text(errors="replace"), re.M):
116
+ return "xelatex" if shutil.which("xelatex") else "lualatex"
117
+ except OSError:
118
+ pass
119
+ return "pdflatex"
120
+
121
+
122
+ def compile_snapshot(snap: Snapshot, work: Path, envs: list[str] | None = None,
123
+ latexmk: str = "latexmk") -> dict:
124
+ """Compile `snap` in `work`; return the PDF path, the `.plx` and `.fls` paths and the log."""
125
+ if snap.mode != "latex":
126
+ raise TexError("not a LaTeX snapshot")
127
+ if work.exists():
128
+ shutil.rmtree(work)
129
+ shutil.copytree(snap.dir, work)
130
+ root = work / snap.root
131
+ cwd = root.parent
132
+ sty = resources.files("prooflens").joinpath("data/prooflens.sty").read_text()
133
+ (cwd / "prooflens.sty").write_text(sty)
134
+ envs = envs or sorted(set(DEFAULT_ENVS) | set(theorem_envs(work)))
135
+ pretex = "\\def\\prooflensEnvs{" + ",".join(envs) + "}\\RequirePackage{prooflens}"
136
+ env = dict(os.environ, shell_escape="f", openout_any="p", TEXMFOUTPUT="",
137
+ SOURCE_DATE_EPOCH=str(source_epoch(snap)), FORCE_SOURCE_DATE="1")
138
+ for k in ("TEXINPUTS",):
139
+ env.pop(k, None)
140
+ cmd = [latexmk, "-norc", ENGINE_FLAG[tex_engine(root)], "-synctex=1", f"-jobname={root.stem}",
141
+ "-interaction=nonstopmode", "-halt-on-error", "-file-line-error",
142
+ f"-usepretex={pretex}", root.name]
143
+ r = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True,
144
+ preexec_fn=_limits, timeout=1800)
145
+ pdf = cwd / f"{root.stem}.pdf"
146
+ plx = cwd / f"{root.stem}.plx"
147
+ if r.returncode != 0 or not pdf.exists() or not plx.exists():
148
+ log = cwd / f"{root.stem}.log"
149
+ tail = log.read_text(errors="replace")[-3000:] if log.exists() else r.stdout[-3000:]
150
+ raise TexError(f"LaTeX compilation of {snap.root} failed:\n{tail}")
151
+ return {"pdf": pdf, "plx": plx, "fls": cwd / f"{root.stem}.fls", "cwd": cwd}
152
+
153
+
154
+ def matching_end(txt: str, start: int, env: str) -> int:
155
+ """Index of the `\\end{env}` closing the environment whose body starts at `start`."""
156
+ pat = re.compile(r"\\(begin|end)\{" + re.escape(env) + r"\}")
157
+ depth = 1
158
+ for m in pat.finditer(txt, start):
159
+ depth += 1 if m.group(1) == "begin" else -1
160
+ if depth == 0:
161
+ return m.start()
162
+ return len(txt)
163
+
164
+
165
+ def env_title(path: Path, line: int, env: str) -> str | None:
166
+ """The optional argument of the environment `env` opening on `line` of `path`: its title as
167
+ printed, in TeX (`Efficiency` for `\\begin{theorem}[Efficiency]`)."""
168
+ try:
169
+ txt = path.read_text(encoding="utf-8", errors="replace")
170
+ except OSError:
171
+ return None
172
+ offs = sum(len(l) + 1 for l in txt.split("\n")[: max(line - 1, 0)])
173
+ m = re.compile(r"\\begin\{" + re.escape(env) + r"\}\s*\[").search(txt, offs)
174
+ if m is None or txt[offs:m.start()].strip():
175
+ return None
176
+ depth, brace, i = 1, 0, m.end()
177
+ while i < len(txt) and depth:
178
+ c = txt[i]
179
+ if c == "\\":
180
+ i += 2
181
+ continue
182
+ brace += (c == "{") - (c == "}")
183
+ if not brace:
184
+ depth += (c == "[") - (c == "]")
185
+ i += 1
186
+ return norm_ws(txt[m.end():i - 1]) or None if not depth else None
187
+
188
+
189
+ def statement_at(path: Path, line: int, env: str) -> str | None:
190
+ """The body of the environment `env` opening on `line` of `path`: after `\\begin{env}[title]`, up to the matching `\\end{env}`."""
191
+ try:
192
+ txt = path.read_text(encoding="utf-8", errors="replace")
193
+ except OSError:
194
+ return None
195
+ offs = sum(len(l) + 1 for l in txt.split("\n")[: max(line - 1, 0)])
196
+ m = re.compile(r"\\begin\{" + re.escape(env) + r"\}(\[[^\]]*\])?").search(txt, offs)
197
+ if m is None:
198
+ return None
199
+ be = matching_end(txt, m.end(), env)
200
+ return txt[m.end():be].strip()
201
+
202
+
203
+ def parse_plx(text: str, cwd: Path, snap_dir: Path) -> tuple[list[Anchor], tuple[float, float]]:
204
+ anchors: dict[int, Anchor] = {}
205
+ prev: dict[int, str] = {}
206
+ page_h = 0.0
207
+ page_w = 0.0
208
+ for raw in text.splitlines():
209
+ parts = raw.split(" ")
210
+ tag = parts[0]
211
+ if tag == "paper":
212
+ page_w = float(parts[1].rstrip("pt")) * 72 / 72.27
213
+ page_h = float(parts[2].rstrip("pt")) * 72 / 72.27
214
+ elif tag == "begin":
215
+ i = int(parts[1])
216
+ anchors[i] = Anchor(i, parts[2], " ".join(parts[4:]), int(parts[3]))
217
+ elif tag == "number" and int(parts[1]) in anchors:
218
+ anchors[int(parts[1])].number = " ".join(parts[2:])
219
+ elif tag == "prev" and int(parts[1]) in anchors:
220
+ prev[int(parts[1])] = " ".join(parts[2:])
221
+ elif tag == "label" and int(parts[1]) in anchors:
222
+ anchors[int(parts[1])].labels.append((parts[2], " ".join(parts[3:])))
223
+ elif tag == "pos" and int(parts[1]) in anchors:
224
+ page, x, y = int(parts[3]), int(parts[4]) / SP_PER_BP, int(parts[5]) / SP_PER_BP
225
+ pt = (page, round(x, 2), round(page_h - y, 2))
226
+ setattr(anchors[int(parts[1])], parts[2], pt)
227
+ out = []
228
+ names: dict[str, list[Path]] = {}
229
+ for a in sorted(anchors.values(), key=lambda a: a.id):
230
+ if a.id in prev and a.number == prev[a.id] and not (a.labels and a.labels[0][1] ==
231
+ a.number):
232
+ # The head's paragraph began before the environment stepped its counter (a run-in
233
+ # heading before it), or the environment is unnumbered: its own first label, if
234
+ # any, gives its number. (A number equal to the previous label and confirmed by the
235
+ # environment's own label is a coincidence of counters, as Lemma 4.1 after (4.1).)
236
+ a.number = a.labels[0][1] if a.labels else None
237
+ f = (cwd / a.file)
238
+ if not f.exists():
239
+ # A TeX older than \CurrentFilePath records the name alone: take the snapshot's
240
+ # only file of that name.
241
+ if not names:
242
+ for p in snap_dir.rglob("*.tex"):
243
+ names.setdefault(p.name, []).append(p)
244
+ cands = names.get(Path(a.file).name, [])
245
+ if len(cands) == 1:
246
+ f = cands[0]
247
+ st = statement_at(f, a.line, a.env)
248
+ a.title = env_title(f, a.line, a.env)
249
+ if st is not None:
250
+ a.statement = norm_ws(st)
251
+ a.source_sha256 = sha256_text(norm_ws(st))
252
+ try:
253
+ a.file = str(f.resolve().relative_to(snap_dir.resolve()))
254
+ except ValueError:
255
+ pass
256
+ out.append(a)
257
+ return out, (round(page_w, 2), round(page_h, 2))
258
+
259
+
260
+ def parse_fls(path: Path, cwd: Path) -> list[str]:
261
+ """Source files read during compilation, relative to the build directory; files of the TeX
262
+ distribution are left out."""
263
+ if not path.exists():
264
+ return []
265
+ seen, out = set(), []
266
+ for line in path.read_text(errors="replace").splitlines():
267
+ if not line.startswith("INPUT "):
268
+ continue
269
+ p = Path(line[6:])
270
+ full = p if p.is_absolute() else (cwd / p)
271
+ try:
272
+ rel = str(full.resolve().relative_to(cwd.resolve()))
273
+ except ValueError:
274
+ continue
275
+ if rel not in seen and not rel.endswith((".aux", ".out", ".toc", ".plx")):
276
+ seen.add(rel)
277
+ out.append(rel)
278
+ return out
279
+
280
+
281
+ MARGIN = 60.0 # points at the top and bottom of a page holding running heads and page numbers
282
+
283
+
284
+ DISPLAY = re.compile(r"\\begin\{(equation|align|gather|multline|eqnarray|flalign|alignat)\*?\}")
285
+ HEADING = re.compile(r"\\(part|chapter|section|subsection|subsubsection|paragraph)\*?[\[{]")
286
+
287
+
288
+ def source_lines(text: list[str], line: int) -> list[list[int]]:
289
+ """The source lines whose boxes place a claim at `line`, best first.
290
+
291
+ SyncTeX attributes the line that opens a display to the paragraph before it, and a
292
+ `\\label` line after a heading to the paragraph after it; so a display is placed by its
293
+ body (less the boxes it shares with the opening line, see `synctex_lines`) and a labelled
294
+ heading by the heading."""
295
+ here = text[line - 1].strip() if 0 < line <= len(text) else ""
296
+ m = DISPLAY.match(here)
297
+ if m:
298
+ end = re.compile(r"\\end\{" + m.group(1) + r"\*?\}")
299
+ for j in range(line, min(len(text), line + 200)):
300
+ if end.search(text[j]):
301
+ body = list(range(line + 1, j + 2))
302
+ return [body, [line]] if body else [[line]]
303
+ if here.startswith("\\label"):
304
+ k = line - 2
305
+ while k >= 0 and not text[k].strip():
306
+ k -= 1
307
+ if k >= 0 and HEADING.match(text[k].strip()):
308
+ return [[k + 1], [line]]
309
+ return [[line]]
310
+
311
+
312
+ def _boxes(cwd: Path, pdf: Path, rel: str, line: int) -> list[dict]:
313
+ for name in (rel, "./" + rel):
314
+ r = subprocess.run(["synctex", "view", "-i", f"{line}:0:{name}", "-o", pdf.name],
315
+ cwd=cwd, capture_output=True, text=True, timeout=60)
316
+ boxes, rec = [], {}
317
+ for l in r.stdout.splitlines():
318
+ k, _, v = l.partition(":")
319
+ if k == "Page":
320
+ rec = {"page": int(v)}
321
+ elif k in ("v", "H", "W") and rec:
322
+ rec[k] = float(v)
323
+ if "v" in rec and "H" in rec and "W" in rec:
324
+ boxes.append(rec)
325
+ rec = {}
326
+ if boxes:
327
+ return boxes
328
+ return []
329
+
330
+
331
+ def _content(boxes: list[dict], page_h: float) -> list[dict]:
332
+ """The boxes inside the text block with height, and with width when any has: zero-width
333
+ boxes are kerns and glue markers, which SyncTeX may place on a neighbouring page."""
334
+ boxes = [b for b in boxes if b["H"] > 0 and MARGIN < b["v"] < page_h - MARGIN]
335
+ wide = [b for b in boxes if b.get("W", 0) > 0]
336
+ return wide or boxes
337
+
338
+
339
+ def synctex_lines(cwd: Path, pdf: Path, snap_dir: Path, locations: list[str],
340
+ page_h: float, anchors: list[Anchor]) -> dict:
341
+ """Where SyncTeX puts each `<file>:<line>` of the source (paths relative to the snapshot).
342
+
343
+ SyncTeX maps a line to every box typeset from it, which may lie on several pages (a macro
344
+ defined on the line and used elsewhere, a running head). Empty boxes and boxes in the page
345
+ margins are dropped; if the rest lie on one page, the anchor is the band covering them,
346
+ `approximate`. If they lie on several pages, the exact anchors of the same file before and
347
+ after the line bound the page; if that does not single one out, the line is `ambiguous`
348
+ and gets no band. The lines queried are those of `source_lines`."""
349
+ out = {}
350
+ texts: dict[str, list[str]] = {}
351
+ for loc in locations:
352
+ path, _, line = loc.rpartition(":")
353
+ try:
354
+ rel = os.path.relpath(snap_dir / path, cwd)
355
+ except ValueError:
356
+ continue
357
+ if path not in texts:
358
+ try:
359
+ texts[path] = (snap_dir / path).read_text(errors="replace").splitlines()
360
+ except OSError:
361
+ texts[path] = []
362
+ boxes = []
363
+ groups = source_lines(texts[path], int(line))
364
+ opening = set()
365
+ src = texts[path]
366
+ if len(groups) > 1 and DISPLAY.match(src[int(line) - 1].strip() if src else ""):
367
+ opening = {tuple(sorted(b.items())) for b in _boxes(cwd, pdf, rel, int(line))}
368
+ for group in groups:
369
+ boxes = [b for n in group for b in _boxes(cwd, pdf, rel, n)]
370
+ if group != [int(line)]:
371
+ boxes = [b for b in boxes if tuple(sorted(b.items())) not in opening]
372
+ boxes = _content(boxes, page_h)
373
+ if boxes:
374
+ break
375
+ pages = sorted({b["page"] for b in boxes})
376
+ if len(pages) > 1 and opening:
377
+ # A display does not break across pages: its page is that of its last line.
378
+ last = {b["page"] for n in groups[0][-2:] for b in _content(_boxes(cwd, pdf, rel, n), page_h)}
379
+ if len(last) == 1:
380
+ pages = [p for p in pages if p in last]
381
+ if len(pages) > 1:
382
+ same = sorted((a.line, a.begin[0]) for a in anchors
383
+ if a.begin and a.file == path)
384
+ lo = max([p for l, p in same if l < int(line)], default=None)
385
+ hi = min([p for l, p in same if l > int(line)], default=None)
386
+ pages = [p for p in pages if (lo is None or p >= lo) and (hi is None or p <= hi)]
387
+ if len(pages) == 1:
388
+ on = [b for b in boxes if b["page"] == pages[0]]
389
+ out[loc] = {"kind": "approximate", "page": pages[0],
390
+ "y0": round(min(b["v"] - b["H"] for b in on), 2),
391
+ "y1": round(max(b["v"] for b in on), 2)}
392
+ elif boxes:
393
+ out[loc] = {"kind": "ambiguous", "pages": sorted({b["page"] for b in boxes})}
394
+ return out
395
+
396
+
397
+ def anchors_for(snap: Snapshot, work: Path, envs: list[str] | None = None) -> dict:
398
+ """Compile and return the anchors, page size, inputs and PDF of a LaTeX snapshot."""
399
+ res = compile_snapshot(snap, work, envs)
400
+ anchors, size = parse_plx(res["plx"].read_text(encoding="utf-8", errors="replace"),
401
+ res["cwd"], work)
402
+ root_dir = (work / snap.root).parent
403
+ inputs = [str((root_dir / p).resolve().relative_to(work.resolve()))
404
+ for p in parse_fls(res["fls"], res["cwd"]) if p != "prooflens.sty"]
405
+ return {"anchors": anchors, "page_size": size, "inputs": inputs, "pdf": res["pdf"],
406
+ "pdf_sha256": sha256_file(res["pdf"]), "cwd": res["cwd"],
407
+ "line_anchors": lambda locs: synctex_lines(res["cwd"], res["pdf"], work, locs,
408
+ size[1], anchors)}
prooflens/util.py ADDED
@@ -0,0 +1,66 @@
1
+ """Hashing, canonical JSON and process helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+
12
+ def sha256_bytes(b: bytes) -> str:
13
+ return hashlib.sha256(b).hexdigest()
14
+
15
+
16
+ def sha256_text(s: str) -> str:
17
+ return sha256_bytes(s.encode("utf-8"))
18
+
19
+
20
+ def sha256_file(p: str | Path) -> str:
21
+ h = hashlib.sha256()
22
+ with open(p, "rb") as f:
23
+ for chunk in iter(lambda: f.read(1 << 20), b""):
24
+ h.update(chunk)
25
+ return h.hexdigest()
26
+
27
+
28
+ def canonical_json(obj) -> str:
29
+ """JSON with sorted keys and no insignificant whitespace: the form that is hashed."""
30
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
31
+
32
+
33
+ def content_id(obj) -> str:
34
+ return sha256_text(canonical_json(obj))
35
+
36
+
37
+ def norm_ws(text: str) -> str:
38
+ """Whitespace normalisation of manuscript source: runs of whitespace become one space."""
39
+ return re.sub(r"\s+", " ", text).strip()
40
+
41
+
42
+ def run(cmd: list[str], cwd: str | Path, env: dict | None = None, timeout: int = 3600):
43
+ return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True,
44
+ timeout=timeout)
45
+
46
+
47
+ def git_head(path: str | Path) -> str | None:
48
+ r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=path, capture_output=True, text=True)
49
+ return r.stdout.strip() if r.returncode == 0 else None
50
+
51
+
52
+ def git_dirty(path: str | Path) -> bool | None:
53
+ r = subprocess.run(["git", "status", "--porcelain"], cwd=path, capture_output=True,
54
+ text=True)
55
+ return bool(r.stdout.strip()) if r.returncode == 0 else None
56
+
57
+
58
+ def write_json(path: str | Path, obj, indent: int | None = 1) -> None:
59
+ path = Path(path)
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ path.write_text(json.dumps(obj, indent=indent, ensure_ascii=False, sort_keys=True) + "\n",
62
+ encoding="utf-8")
63
+
64
+
65
+ def read_json(path: str | Path):
66
+ return json.loads(Path(path).read_text(encoding="utf-8"))