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.
- machinechecked-0.1.0.dist-info/METADATA +108 -0
- machinechecked-0.1.0.dist-info/RECORD +58 -0
- machinechecked-0.1.0.dist-info/WHEEL +5 -0
- machinechecked-0.1.0.dist-info/entry_points.txt +3 -0
- machinechecked-0.1.0.dist-info/licenses/LICENSE +201 -0
- machinechecked-0.1.0.dist-info/top_level.txt +1 -0
- prooflens/__init__.py +1 -0
- prooflens/__main__.py +3 -0
- prooflens/assess.py +504 -0
- prooflens/audit.py +115 -0
- prooflens/bundle.py +70 -0
- prooflens/cli.py +542 -0
- prooflens/config.py +145 -0
- prooflens/data/ProofLensProbe.lean +469 -0
- prooflens/data/prooflens.sty +75 -0
- prooflens/data/schema/evidence.schema.json +24 -0
- prooflens/data/schema/inventory.schema.json +55 -0
- prooflens/data/schema/manifest-claim.schema.json +126 -0
- prooflens/data/schema/manifest-index.schema.json +128 -0
- prooflens/evidence.py +60 -0
- prooflens/export.py +227 -0
- prooflens/formalization.py +106 -0
- prooflens/manifest.py +296 -0
- prooflens/pdfclaims.py +129 -0
- prooflens/pipeline.py +331 -0
- prooflens/probe.py +126 -0
- prooflens/promote.py +204 -0
- prooflens/review_queue.py +138 -0
- prooflens/reviews.py +105 -0
- prooflens/schemas.py +19 -0
- prooflens/server.py +63 -0
- prooflens/simple.py +628 -0
- prooflens/sources.py +164 -0
- prooflens/tex.py +408 -0
- prooflens/util.py +66 -0
- prooflens/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- prooflens/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- prooflens/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- prooflens/web/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- prooflens/web/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- prooflens/web/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- prooflens/web/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- prooflens/web/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- prooflens/web/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- prooflens/web/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- prooflens/web/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- prooflens/web/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- prooflens/web/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- prooflens/web/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- prooflens/web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- prooflens/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- prooflens/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- prooflens/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- prooflens/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- prooflens/web/assets/index-B2GDDbsW.css +1 -0
- prooflens/web/assets/index-DN7uLe7p.js +285 -0
- prooflens/web/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
- 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"))
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|