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/bundle.py ADDED
@@ -0,0 +1,70 @@
1
+ """One self-contained HTML file of a site, readable straight from disk.
2
+
3
+ Browsers refuse `fetch` from pages opened as files, so the bundle carries everything in the page:
4
+ the app's script and styles (fonts as data URIs, WOFF2 only), the pdf.js worker's code, every
5
+ data file of the site in `window.__PROOFLENS__` (keyed by the path the app would fetch), and the
6
+ PDFs, base64, in `window.__PROOFLENS_PDF__`. The data scripts come first, so they are defined
7
+ before the app (a deferred module) runs.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import json
14
+ import re
15
+ from pathlib import Path
16
+
17
+
18
+ def _script_safe(text: str) -> str:
19
+ """Text that cannot close the <script> element it sits in."""
20
+ return text.replace("</", "<\\/")
21
+
22
+
23
+ def _inline_css(css: str, assets: Path) -> str:
24
+ def font(m):
25
+ name = m.group(1)
26
+ f = assets / name
27
+ if not f.exists():
28
+ return m.group(0)
29
+ b64 = base64.b64encode(f.read_bytes()).decode()
30
+ return f'url(data:font/woff2;base64,{b64}) format("woff2")'
31
+ # Keep only the WOFF2 source of each font face, inlined.
32
+ css = re.sub(r',\s*url\([^)]*\.(?:woff|ttf)\)\s*format\("(?:woff|truetype)"\)', "", css)
33
+ return re.sub(r'url\((?:\./)?(?:assets/)?([^)]+\.woff2)\)\s*format\("woff2"\)', font, css)
34
+
35
+
36
+ def bundle(site: Path, out: Path, title: str | None = None) -> dict:
37
+ html = (site / "index.html").read_text(encoding="utf-8")
38
+ assets = site / "assets"
39
+ js_src = re.search(r'<script type="module"[^>]*src="\./(assets/[^"]+\.js)"[^>]*></script>', html)
40
+ css_href = re.search(r'<link rel="stylesheet"[^>]*href="\./(assets/[^"]+\.css)"[^>]*>', html)
41
+ if not js_src or not css_href:
42
+ raise RuntimeError("the site's index.html does not reference its script and styles")
43
+ js = (site / js_src.group(1)).read_text(encoding="utf-8")
44
+ css = _inline_css((site / css_href.group(1)).read_text(encoding="utf-8"), assets)
45
+ worker = next(assets.glob("pdf.worker*.mjs"), None)
46
+
47
+ data = {}
48
+ for f in sorted((site / "data").rglob("*.json")):
49
+ data[str(f.relative_to(site))] = json.loads(f.read_text(encoding="utf-8"))
50
+ pdfs = {str(f.relative_to(site)): base64.b64encode(f.read_bytes()).decode()
51
+ for f in sorted((site / "data").rglob("*.pdf"))}
52
+
53
+ head = ["<script>window.__PROOFLENS__ = " +
54
+ _script_safe(json.dumps(data, ensure_ascii=False, separators=(",", ":"))) +
55
+ ";</script>",
56
+ "<script>window.__PROOFLENS_PDF__ = " + json.dumps(pdfs) + ";</script>"]
57
+ if worker:
58
+ head.append("<script>window.__PROOFLENS_WORKER__ = " +
59
+ _script_safe(json.dumps(worker.read_text(encoding="utf-8"))) + ";</script>")
60
+ head.append("<style>" + css + "</style>")
61
+ html = html.replace(css_href.group(0), "")
62
+ html = html.replace(js_src.group(0), "")
63
+ html = html.replace("</head>", "\n".join(head) + "\n</head>", 1)
64
+ html = html.replace("</body>", '<script type="module">' + _script_safe(js) +
65
+ "</script>\n</body>", 1)
66
+ if title:
67
+ html = re.sub(r"<title>.*?</title>", f"<title>{title}</title>", html, count=1)
68
+ out.parent.mkdir(parents=True, exist_ok=True)
69
+ out.write_text(html, encoding="utf-8")
70
+ return {"out": out, "size": out.stat().st_size, "files": len(data), "pdfs": len(pdfs)}
prooflens/cli.py ADDED
@@ -0,0 +1,542 @@
1
+ """`machinechecked`: read a paper beside its Lean formalisation.
2
+
3
+ Everyday commands, run from anywhere inside a Lean project:
4
+
5
+ machinechecked view [paper] open the reader (the paper is found if not given)
6
+ machinechecked build [paper] write the reader as one HTML file to share
7
+ machinechecked check [paper] report what the Lean side establishes, for CI
8
+
9
+ A project with a `machinechecked.toml` (an acceptance layer: locked statements, reviews) gets
10
+ the full assessment; any other project gets the zero-configuration view, whose only file in the
11
+ repository is `machinechecked.links.json`. The other commands are for that full assessment.
12
+ `prooflens` is another name for the same command.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import functools
19
+ import http.server
20
+ import json
21
+ import re
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from . import config as cfgmod
26
+ from .schemas import validate
27
+
28
+
29
+ def _web_dist(arg: str | None) -> Path | None:
30
+ """The built web app: given with --web, else the one shipped in the package."""
31
+ if arg:
32
+ return Path(arg).resolve()
33
+ shipped = Path(__file__).resolve().parent / "web"
34
+ return shipped if (shipped / "index.html").exists() else None
35
+
36
+
37
+ MISSING = {
38
+ "lake": "install elan (https://github.com/leanprover/elan) and put ~/.elan/bin on PATH",
39
+ "latexmk": "install TeX Live with latexmk, or point ProofLens at the paper's PDF",
40
+ "synctex": "install TeX Live (it provides synctex)",
41
+ "pdftohtml": "install poppler (poppler-utils) to read a PDF, or give the paper's LaTeX source",
42
+ "pdfinfo": "install poppler (poppler-utils) to read a PDF, or give the paper's LaTeX source",
43
+ }
44
+
45
+
46
+ # -- mode selection ---------------------------------------------------------------------------
47
+
48
+ def _found_config() -> Path | None:
49
+ return next((Path(n) for n in cfgmod.CONFIG_NAMES if Path(n).exists()), None)
50
+
51
+
52
+ def _config_arg(a) -> Path:
53
+ """The configuration of the full assessment: given with -c, else the one in the current
54
+ directory."""
55
+ c = getattr(a, "config", None) or _found_config()
56
+ if c is None:
57
+ sys.exit("no machinechecked.toml here (pass one with -c)")
58
+ return Path(c)
59
+
60
+
61
+ def _assurance_config(a) -> Path | None:
62
+ """The configuration that selects the full assessment: given with -c, or in the current
63
+ directory."""
64
+ if getattr(a, "config", None):
65
+ return Path(a.config)
66
+ return _found_config()
67
+
68
+
69
+ def _simple_target(a):
70
+ from .simple import find_lake_root, find_papers
71
+ root = find_lake_root(Path.cwd().resolve())
72
+ if root is None:
73
+ sys.exit("no Lean project here: run machinechecked inside a directory with a lakefile")
74
+ if a.paper and re.match(r"https?://", a.paper):
75
+ from . import formalization as fz
76
+ fy = fz.load(root) or {}
77
+ title = next((s.get("title") for s in fz.formalized_sources(fy) if "error" not in fy
78
+ and s.get("id") == a.paper), None)
79
+ paper = _fetch_paper(root, a.paper, title)
80
+ elif a.paper:
81
+ paper = Path(a.paper).resolve()
82
+ if not paper.exists():
83
+ sys.exit(f"{a.paper}: no such file")
84
+ else:
85
+ found = find_papers(root)
86
+ if not found:
87
+ from . import formalization as fz
88
+ fy = fz.load(root) or {}
89
+ urls = [s for s in fz.formalized_sources(fy) if "error" not in fy
90
+ and re.match(r"https?://", str(s.get("id") or ""))]
91
+ if not urls:
92
+ sys.exit("no paper found (no .tex with \\documentclass, no .pdf): pass its path "
93
+ "or URL")
94
+ print("The paper is not in the repository; its formalization.yaml names:")
95
+ for i, s in enumerate(urls, 1):
96
+ print(f" {i}. {s.get('title') or s['id']}\n {s['id']}")
97
+ if len(urls) == 1:
98
+ k = "1"
99
+ elif not sys.stdin.isatty():
100
+ sys.exit("pass the one to use: machinechecked view <URL>")
101
+ else:
102
+ k = input("Which one? ")
103
+ try:
104
+ s = urls[int(k) - 1]
105
+ except (ValueError, IndexError):
106
+ sys.exit("no paper chosen")
107
+ return root, _fetch_paper(root, s["id"], s.get("title"))
108
+ if len(found) > 1:
109
+ print("Several papers found:")
110
+ for i, f in enumerate(found, 1):
111
+ print(f" {i}. {f.relative_to(root)}")
112
+ if not sys.stdin.isatty():
113
+ sys.exit("pass the one to use: machinechecked view <paper>")
114
+ k = input("Which one? ")
115
+ try:
116
+ paper = found[int(k) - 1]
117
+ except (ValueError, IndexError):
118
+ sys.exit("no paper chosen")
119
+ else:
120
+ paper = found[0]
121
+ try:
122
+ paper.relative_to(root)
123
+ except ValueError:
124
+ sys.exit(f"{paper} is not inside the Lean project {root}")
125
+ return root, paper
126
+
127
+
128
+ def _settings(a, root: Path | None):
129
+ """Record --issues and --site-url: in the links file (zero configuration); a full
130
+ assessment sets them in its configuration."""
131
+ issues, url = getattr(a, "issues", None), getattr(a, "site_url", None)
132
+ author = getattr(a, "author", None)
133
+ private = getattr(a, "private_repository", None)
134
+ if not issues and not url and not author and private is None:
135
+ return
136
+ if root is None:
137
+ sys.exit("set `issues`, `site_url`, `author` and `repository_private` under [project] in "
138
+ "machinechecked.toml")
139
+ from .simple import save_settings
140
+ try:
141
+ save_settings(root, issues=cfgmod.issue_repo(issues, "--issues") if issues else None,
142
+ site_url=url, author=author, repository_private=private)
143
+ except cfgmod.ConfigError as e:
144
+ sys.exit(str(e))
145
+
146
+
147
+ def _fetch_paper(root: Path, url: str, title: str | None = None) -> Path:
148
+ """A paper given by URL, downloaded once into the working directory (never into the
149
+ repository), with its address and title beside it."""
150
+ import hashlib
151
+ import urllib.request
152
+ d = root / cfgmod.work_dir_name(root) / "papers"
153
+ name = re.sub(r"[^\w.-]+", "_", url.rstrip("/").rsplit("/", 1)[-1]) or "paper"
154
+ if not name.lower().endswith(".pdf"):
155
+ name += ".pdf"
156
+ out = d / f"{hashlib.sha256(url.encode()).hexdigest()[:10]}-{name}"
157
+ if not out.exists():
158
+ d.mkdir(parents=True, exist_ok=True)
159
+ print(f"downloading {url}")
160
+ try:
161
+ with urllib.request.urlopen(url, timeout=120) as r:
162
+ data = r.read(200_000_001)
163
+ except Exception as e:
164
+ sys.exit(f"could not download {url}: {e}")
165
+ if len(data) > 200_000_000 or not data.startswith(b"%PDF"):
166
+ sys.exit(f"{url} is not a PDF (or is larger than 200 MB)")
167
+ out.write_bytes(data)
168
+ out.with_suffix(".json").write_text(json.dumps({"url": url, "title": title}))
169
+ return out
170
+
171
+
172
+ def _simple_site(root: Path, paper: Path, web: str | None):
173
+ from .simple import build_view, write_view_site
174
+ out = root / cfgmod.work_dir_name(root)
175
+ view = build_view(root, paper, out, log=lambda m: print(" " + m))
176
+ site = out / "site"
177
+ index = write_view_site(view, site, _web_dist(web), root)
178
+ return site, index, view
179
+
180
+
181
+ def _print_simple(index):
182
+ for n in index.get("notes", []):
183
+ print(f"note: {n}")
184
+ for s, k in sorted(index["summary"]["P"].items()):
185
+ print(f" {k:4d} {s}")
186
+
187
+
188
+ # -- everyday commands ------------------------------------------------------------------------
189
+
190
+ def cmd_view(a):
191
+ if _assurance_config(a):
192
+ from .pipeline import build
193
+ from .server import serve
194
+ _settings(a, None)
195
+ cfg = cfgmod.load(_assurance_config(a))
196
+ try:
197
+ res = build(cfg, web_dist=_web_dist(a.web), reuse_lean=True)
198
+ except RuntimeError:
199
+ res = build(cfg, web_dist=_web_dist(a.web))
200
+ serve(res["site"], a.port, None, not a.no_browser)
201
+ return
202
+ from .server import serve
203
+ from .simple import save_link
204
+ root, paper = _simple_target(a)
205
+ _settings(a, root)
206
+ print(f"MachineChecked: {paper.relative_to(root)} in {root.name}")
207
+ site, index, view = _simple_site(root, paper, a.web)
208
+ _print_simple(index)
209
+ key = view["body"]["paper"]
210
+
211
+ def on_link(req):
212
+ save_link(root, key, req["key"], req.get("lean"), req.get("reject"))
213
+ _simple_site(root, paper, a.web)
214
+
215
+ serve(site, a.port, on_link, not a.no_browser)
216
+
217
+
218
+ def cmd_build_simple(a):
219
+ from .bundle import bundle
220
+ root, paper = _simple_target(a)
221
+ _settings(a, root)
222
+ site, index, _ = _simple_site(root, paper, a.web)
223
+ _print_simple(index)
224
+ if a.site:
225
+ import shutil
226
+ dest = Path(a.site).resolve()
227
+ if dest == root.resolve() or root.resolve().is_relative_to(dest):
228
+ sys.exit(f"--site {a.site}: choose a new directory, not the project")
229
+ if dest.exists():
230
+ if not (dest / "audit.json").exists() and any(dest.iterdir()):
231
+ sys.exit(f"--site {a.site}: not empty and not a previous site; choose another")
232
+ shutil.rmtree(dest)
233
+ shutil.copytree(site, dest)
234
+ print(f"wrote the reader to {dest}: publish this directory as it is "
235
+ "(audit.json describes it)")
236
+ return
237
+ out = Path(a.out or (root / "MachineChecked.html")).resolve()
238
+ res = bundle(site, out, f"MachineChecked — {index['papers'][0]['title']}")
239
+ print(f"wrote {res['out']} ({res['size'] / 1e6:.1f} MB): open it in a browser")
240
+
241
+
242
+ def cmd_check(a):
243
+ if getattr(a, "json", False):
244
+ return _check_json(a)
245
+ if _assurance_config(a):
246
+ a.config = str(_assurance_config(a))
247
+ a.release = None; a.skip_lean = a.skip_tex = False; a.lake = "lake"
248
+ a.no_lean_build = False; a.reuse_lean = False; a.fail_on_violation = True
249
+ return cmd_build(a)
250
+ root, paper = _simple_target(a)
251
+ site, index, view = _simple_site(root, paper, a.web)
252
+ _print_simple(index)
253
+ bad = [(c["number"], l["name"], l["status"]) for c in view["body"]["claims"]
254
+ for l in c["links"] if l["class"] != "proved"]
255
+ for b in bad:
256
+ print(f"FAIL {b[0]} -> {b[1]}: {b[2]}")
257
+ if bad:
258
+ sys.exit(1)
259
+ n = sum(len(c["links"]) for c in view["body"]["claims"])
260
+ if not n:
261
+ print("no claim is linked yet: confirm links in `machinechecked view`")
262
+ return
263
+ print(f"all {n} linked declaration(s) are checked by Lean (whether each says what the paper "
264
+ "says is not reviewed by this check)")
265
+
266
+
267
+ CHECK_NOTE = ("Statuses say what Lean establishes about the linked declarations. Whether a Lean "
268
+ "statement says what the paper says is not checked here; that takes a reviewer.")
269
+
270
+
271
+ def _check_json(a):
272
+ """`check --json`: every claim with its status and links, as one JSON document on stdout
273
+ (progress goes to stderr). Exit 1 if a linked declaration is missing or not proved."""
274
+ import contextlib
275
+ import json
276
+ with contextlib.redirect_stdout(sys.stderr):
277
+ if _assurance_config(a):
278
+ from .pipeline import build
279
+ cfg = cfgmod.load(_assurance_config(a))
280
+ idx = build(cfg, web_dist=_web_dist(a.web))["index"]
281
+ claims = [{"id": c["id"], "paper": c["paper"], "number": c.get("number"),
282
+ "title": c.get("title"), "status": c["status"], "class": c["class"]}
283
+ for c in idx["claims"]]
284
+ out = {"mode": "full", "release": idx["release"],
285
+ "project": idx.get("project", {}).get("commit"),
286
+ "findings": idx["findings"]["total"]}
287
+ failed = bool(idx["findings"]["total"].get("violation"))
288
+ else:
289
+ root, paper = _simple_target(a)
290
+ site, idx, view = _simple_site(root, paper, a.web)
291
+ body = view["body"]
292
+ claims = [{"id": c["id"], "number": c["number"], "title": c["title"],
293
+ "status": c["status"], "class": c["class"],
294
+ "links": [{"name": l["name"], "status": l["status"], "class": l["class"],
295
+ "source": l.get("source")} for l in c["links"]],
296
+ "suggestions": [{"name": x["name"], "why": x.get("why")}
297
+ for x in c.get("suggestions", [])]}
298
+ for c in body["claims"]]
299
+ out = {"mode": "simple", "paper": body["paper"], "title": body["title"],
300
+ "release": idx["release"], "commit": body.get("commit"),
301
+ "modules": {"built": body["project"]["built"],
302
+ "not_built": body["project"]["unbuilt"]},
303
+ "notes": body["notes"]}
304
+ failed = any(l["class"] != "proved" for c in claims for l in c["links"])
305
+ summary = {}
306
+ for c in claims:
307
+ summary[c["status"]] = summary.get(c["status"], 0) + 1
308
+ print(json.dumps({"schema": "machinechecked/check@1", **out, "summary": summary,
309
+ "claims": claims, "ok": not failed, "note": CHECK_NOTE}, indent=1))
310
+ if failed:
311
+ sys.exit(1)
312
+
313
+
314
+ def cmd_build(a):
315
+ if not _assurance_config(a):
316
+ return cmd_build_simple(a)
317
+ from .pipeline import build
318
+ cfg = cfgmod.load(_config_arg(a))
319
+ res = build(cfg, release=a.release, skip_lean=a.skip_lean, skip_tex=a.skip_tex, lake=a.lake,
320
+ web_dist=_web_dist(a.web), lean_build=not a.no_lean_build,
321
+ reuse_lean=a.reuse_lean)
322
+ idx = res["index"]
323
+ for pid, s in idx["summary"].items():
324
+ print(f"paper {pid}:")
325
+ for k, v in sorted(s.items()):
326
+ print(f" {v:4d} {k}")
327
+ print("findings:", ", ".join(f"{v} {k}" for k, v in sorted(idx["findings"]["total"].items()))
328
+ or "none")
329
+ if a.fail_on_violation and idx["findings"]["total"].get("violation"):
330
+ sys.exit(1)
331
+
332
+
333
+ def cmd_site(a):
334
+ from .pipeline import rebuild_site
335
+ cfg = cfgmod.load(_config_arg(a))
336
+ res = rebuild_site(cfg, _web_dist(a.web), a.release)
337
+ print(f"site of release {res['release']} written to {res['site']}")
338
+
339
+
340
+ def cmd_reassess(a):
341
+ from .pipeline import reassess
342
+ cfg = cfgmod.load(_config_arg(a))
343
+ res = reassess(cfg, _web_dist(a.web), a.release)
344
+ print(f"site of release {res['release']} written to {res['site']}")
345
+
346
+
347
+ def cmd_bundle(a):
348
+ from .bundle import bundle
349
+ cfg = cfgmod.load(_config_arg(a))
350
+ site = Path(a.site).resolve() if a.site else cfg.out_dir / "site"
351
+ res = bundle(site, Path(a.out).resolve(), a.title)
352
+ print(f"wrote {res['out']}: {res['size'] / 1e6:.1f} MB, {res['files']} data files, "
353
+ f"{res['pdfs']} PDFs; open it in a browser, no server needed")
354
+
355
+
356
+ def cmd_export(a):
357
+ from .export import ExportError, export
358
+ cfg = cfgmod.load(_config_arg(a))
359
+ try:
360
+ res = export(cfg, Path(a.out).resolve(), a.release, _web_dist(a.web))
361
+ except ExportError as e:
362
+ sys.exit(str(e))
363
+ print(f"exported release {res['release']} to {res['out']}")
364
+
365
+
366
+ def cmd_serve(a):
367
+ cfg = cfgmod.load(_config_arg(a))
368
+ site = Path(a.dir).resolve() if a.dir else cfg.out_dir / "site"
369
+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(site))
370
+ print(f"serving {site} on http://127.0.0.1:{a.port}")
371
+ http.server.ThreadingHTTPServer(("127.0.0.1", a.port), handler).serve_forever()
372
+
373
+
374
+ def cmd_review(a):
375
+ import shlex
376
+ import subprocess
377
+ from . import promote as pr
378
+ from .pipeline import reassess
379
+ cfg = cfgmod.load(_config_arg(a))
380
+ try:
381
+ steps = pr.plan(cfg, pr.load_reports([Path(f) for f in a.reports]),
382
+ a.accept_same_family)
383
+ except pr.PromoteError as e:
384
+ sys.exit(f"machinechecked: {e}")
385
+ ok = []
386
+ for r, cur, why in steps:
387
+ u = r.get("unit", {})
388
+ head = f"{u.get('name')} ({u.get('kind')}) by {r.get('reviewer', {}).get('id')}: " \
389
+ f"{r.get('verdict', {}).get('value')}"
390
+ if why:
391
+ print(f"REFUSED {head}")
392
+ for w in why:
393
+ print(f" - {w}")
394
+ else:
395
+ print(f"OK {head}\n {pr.marker(r, cur)}")
396
+ ok.append((r, cur))
397
+ if not ok:
398
+ sys.exit("nothing to promote")
399
+ if not a.yes:
400
+ if not sys.stdin.isatty():
401
+ sys.exit("pass --yes to write the markers without a prompt")
402
+ if input(f"Write {len(ok)} marker(s) and archive the report(s)? [y/N] ").strip().lower() \
403
+ not in ("y", "yes"):
404
+ sys.exit("nothing written")
405
+ done = pr.apply(cfg, ok)
406
+ for d in done:
407
+ print(f"wrote {d['marker']} to {d['file']}")
408
+ old = {c["id"]: c["status"] for c in pr.latest_record(cfg)["body"]["assessment"]["claims"]}
409
+ res = reassess(cfg, _web_dist(None))
410
+ for c in res["index"]["claims"]:
411
+ if old.get(c["id"]) != c["status"]:
412
+ print(f" {c['id']}: {old.get(c['id'])} -> {c['status']}")
413
+ if cfg.review_check:
414
+ print(f"running {cfg.review_check}")
415
+ r = subprocess.run(shlex.split(cfg.review_check), cwd=cfg.root)
416
+ if r.returncode:
417
+ sys.exit(r.returncode)
418
+
419
+
420
+ def cmd_validate(a):
421
+ validate(json.loads(Path(a.file).read_text()), a.schema)
422
+ print(f"{a.file}: valid {a.schema}")
423
+
424
+
425
+ def main(argv=None):
426
+ name = Path(sys.argv[0]).name
427
+ p = argparse.ArgumentParser(prog=name if name in ("machinechecked", "prooflens")
428
+ else "machinechecked", description=__doc__.split("\n\n")[0],
429
+ formatter_class=argparse.RawDescriptionHelpFormatter,
430
+ epilog="\n\n".join(__doc__.split("\n\n")[1:]))
431
+ sub = p.add_subparsers(dest="cmd", required=True, metavar="{view,build,check}")
432
+
433
+ w = sub.add_parser("view", help="open the reader")
434
+ w.add_argument("paper", nargs="?")
435
+ w.add_argument("--issues", metavar="OWNER/REPO",
436
+ help="the GitHub repository where the reader's Raise issue opens reports")
437
+ w.add_argument("--site-url", help="the public address of the published reader, "
438
+ "for links to claims")
439
+ w.add_argument("--author", help="the name the audit is published under (in audit.json)")
440
+ w.add_argument("--private-repository", action=argparse.BooleanOptionalAction, default=None,
441
+ help="the Lean repository is private: the reader does not show its name "
442
+ "(its source links still name it, and open only for people with access)")
443
+ w.add_argument("-c", "--config", help=argparse.SUPPRESS)
444
+ w.add_argument("--port", type=int, default=8017)
445
+ w.add_argument("--no-browser", action="store_true")
446
+ w.add_argument("--web", help=argparse.SUPPRESS)
447
+ w.set_defaults(func=cmd_view)
448
+
449
+ b = sub.add_parser("build", help="write the reader as one HTML file to share")
450
+ b.add_argument("paper", nargs="?")
451
+ b.add_argument("--issues", metavar="OWNER/REPO",
452
+ help="the GitHub repository where the reader's Raise issue opens reports")
453
+ b.add_argument("--site-url", help="the public address of the published reader, "
454
+ "for links to claims")
455
+ b.add_argument("--author", help="the name the audit is published under (in audit.json)")
456
+ b.add_argument("--private-repository", action=argparse.BooleanOptionalAction, default=None,
457
+ help="the Lean repository is private: the reader does not show its name "
458
+ "(its source links still name it, and open only for people with access)")
459
+ b.add_argument("--out", help="the HTML file (default: MachineChecked.html at the project root)")
460
+ b.add_argument("--site", metavar="DIR", help="write the reader as a directory to publish "
461
+ "(for example on GitHub Pages) instead of one HTML file")
462
+ b.add_argument("-c", "--config", help=argparse.SUPPRESS)
463
+ b.add_argument("--release", help=argparse.SUPPRESS)
464
+ b.add_argument("--skip-lean", action="store_true", help=argparse.SUPPRESS)
465
+ b.add_argument("--skip-tex", action="store_true", help=argparse.SUPPRESS)
466
+ b.add_argument("--lake", default="lake", help=argparse.SUPPRESS)
467
+ b.add_argument("--no-lean-build", action="store_true", help=argparse.SUPPRESS)
468
+ b.add_argument("--reuse-lean", action="store_true", help=argparse.SUPPRESS)
469
+ b.add_argument("--web", help=argparse.SUPPRESS)
470
+ b.add_argument("--fail-on-violation", action="store_true", help=argparse.SUPPRESS)
471
+ b.set_defaults(func=cmd_build)
472
+
473
+ k = sub.add_parser("check", help="report what the Lean side establishes (exit 1 if a "
474
+ "linked declaration is missing or unproved)")
475
+ k.add_argument("paper", nargs="?")
476
+ k.add_argument("--json", action="store_true",
477
+ help="print every claim's status and links as JSON (for scripts and agents)")
478
+ k.add_argument("-c", "--config", help=argparse.SUPPRESS)
479
+ k.add_argument("--web", help=argparse.SUPPRESS)
480
+ k.set_defaults(func=cmd_check)
481
+
482
+ # The full assessment's own operations; not listed.
483
+ t = sub.add_parser("site")
484
+ t.add_argument("-c", "--config", default=None)
485
+ t.add_argument("--release")
486
+ t.add_argument("--web")
487
+ t.set_defaults(func=cmd_site)
488
+
489
+ r = sub.add_parser("reassess")
490
+ r.add_argument("-c", "--config", default=None)
491
+ r.add_argument("--release")
492
+ r.add_argument("--web")
493
+ r.set_defaults(func=cmd_reassess)
494
+
495
+ n = sub.add_parser("bundle")
496
+ n.add_argument("-c", "--config", default=None)
497
+ n.add_argument("--site")
498
+ n.add_argument("--out", required=True)
499
+ n.add_argument("--title")
500
+ n.set_defaults(func=cmd_bundle)
501
+
502
+ e = sub.add_parser("export")
503
+ e.add_argument("-c", "--config", default=None)
504
+ e.add_argument("--release")
505
+ e.add_argument("--out", required=True)
506
+ e.add_argument("--web")
507
+ e.set_defaults(func=cmd_export)
508
+
509
+ s = sub.add_parser("serve")
510
+ s.add_argument("-c", "--config", default=None)
511
+ s.add_argument("--dir")
512
+ s.add_argument("--port", type=int, default=8017)
513
+ s.set_defaults(func=cmd_serve)
514
+
515
+ rv = sub.add_parser("review")
516
+ rvs = rv.add_subparsers(dest="action", required=True)
517
+ pm = rvs.add_parser("promote", help="turn review reports into review markers (maintainer)")
518
+ pm.add_argument("reports", nargs="+", help="files exported from the review queue")
519
+ pm.add_argument("-c", "--config", default=None)
520
+ pm.add_argument("--yes", action="store_true", help="write without asking")
521
+ pm.add_argument("--accept-same-family", action="store_true",
522
+ help="accept a model reviewer of the author's family, or of unknown family")
523
+ pm.set_defaults(func=cmd_review)
524
+
525
+ v = sub.add_parser("validate")
526
+ v.add_argument("file")
527
+ v.add_argument("--schema", required=True,
528
+ choices=["inventory", "evidence", "manifest-index", "manifest-claim"])
529
+ v.set_defaults(func=cmd_validate)
530
+
531
+ a = p.parse_args(argv)
532
+ try:
533
+ a.func(a)
534
+ except FileNotFoundError as e:
535
+ hint = MISSING.get(Path(str(e.filename or "")).name)
536
+ if not hint:
537
+ raise
538
+ sys.exit(f"machinechecked: `{Path(e.filename).name}` not found: {hint}")
539
+
540
+
541
+ if __name__ == "__main__":
542
+ main()