lean-vouch 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.
lean_vouch/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Tools for human review of Lean definitions and statements in doc-gen4 pages."""
lean_vouch/cli.py ADDED
@@ -0,0 +1,362 @@
1
+ """Set up lean-vouch for a Lake project, build what it needs, and serve it. From the project's root:
2
+
3
+ lean-vouch
4
+ lean-vouch MyLibrary My.Module
5
+
6
+ It reviews every library in the project, or just the libraries and modules you name. Safe to rerun: it refreshes the
7
+ docs and graph after you change the code.
8
+ """
9
+ import argparse
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tomllib
18
+ import unicodedata
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from urllib.request import urlopen
22
+
23
+ from lean_vouch.server import ID, VERSION
24
+
25
+ HERE = Path(__file__).resolve().parent
26
+ MINIMUM = (4, 30) # the oldest Lean this has been run on, end to end; see docs/internals.md
27
+ TESTED = (4, 35) # the newest
28
+ HOME = Path(".lean-vouch")
29
+
30
+ DOCBUILD = """\
31
+ # Builds this project's API documentation with doc-gen4, for lean-vouch.
32
+ name = "docbuild"
33
+ reservoir = false
34
+ version = "0.1.0"
35
+ packagesDir = "../../.lake/packages"
36
+
37
+ [[require]]
38
+ scope = "leanprover"
39
+ name = "doc-gen4"
40
+ rev = "{version}"
41
+
42
+ [[require]]
43
+ name = "{package}"
44
+ path = "../../"
45
+ """
46
+
47
+ # Everything here can be rebuilt but the marks, and quiz answers stay with the person.
48
+ GITIGNORE = """\
49
+ *
50
+ !.gitignore
51
+ !marks/
52
+ !marks/**
53
+ """
54
+
55
+
56
+ def fail(message):
57
+ sys.exit(f"lean-vouch: {message}")
58
+
59
+
60
+ def toolchain():
61
+ """The project's Lean version, as its tag and (major, minor), which is None if it isn't a release."""
62
+ if not Path("lean-toolchain").exists():
63
+ fail("this project has no lean-toolchain file.")
64
+ tag = Path("lean-toolchain").read_text(encoding="utf-8").strip().rsplit(":", 1)[-1]
65
+ found = re.fullmatch(r"v(\d+)\.(\d+)\.\d+.*", tag)
66
+ return tag, (int(found[1]), int(found[2])) if found else None
67
+
68
+
69
+ def check_version(tag, version):
70
+ """Say so now, before a long doc-gen4 build that can't succeed."""
71
+ if version is None:
72
+ print(f"lean-vouch: {tag} isn't a Lean release, so this may not work. "
73
+ f"Tested on v{MINIMUM[0]}.{MINIMUM[1]} to v{TESTED[0]}.{TESTED[1]}.", flush=True)
74
+ elif version < MINIMUM:
75
+ fail(f"this project is on Lean {tag}; lean-vouch needs v{MINIMUM[0]}.{MINIMUM[1]} or newer.")
76
+ elif version > TESTED:
77
+ print(f"lean-vouch: Lean {tag} is newer than it's been tested on (v{TESTED[0]}.{TESTED[1]}). "
78
+ "If the graph step fails, please open an issue.", flush=True)
79
+
80
+
81
+ def lakefile_toml():
82
+ try:
83
+ return tomllib.loads(Path("lakefile.toml").read_text(encoding="utf-8"))
84
+ except tomllib.TOMLDecodeError as error:
85
+ fail(f"couldn't read lakefile.toml: {error}")
86
+
87
+
88
+ def package_name():
89
+ if Path("lakefile.toml").exists():
90
+ found = lakefile_toml().get("name")
91
+ elif Path("lakefile.lean").exists():
92
+ found = re.search(r'^package\s+[«"]?([^\s»"]+)', Path("lakefile.lean").read_text(encoding="utf-8"), re.M)
93
+ found = found and found[1]
94
+ else:
95
+ fail("run this from the root of a Lake project: there's no lakefile here.")
96
+ if not found or not isinstance(found, str):
97
+ fail("couldn't read the package name from the lakefile.")
98
+ return found.strip("«»")
99
+
100
+
101
+ def library_names():
102
+ """The project's libraries, as its lakefile declares them."""
103
+ if Path("lakefile.toml").exists():
104
+ return [library["name"].strip("«»") for library in lakefile_toml().get("lean_lib", [])]
105
+ found = re.findall(r"^(?:@\[.*?\]\s*)?lean_lib\s+(\S+)", Path("lakefile.lean").read_text(encoding="utf-8"), re.M)
106
+ return [name.strip('«»"') for name in found]
107
+
108
+
109
+ def modules_of(names):
110
+ """What to review: the modules of the libraries named, or of them all, and any modules named.
111
+ Lake lists a library's modules, since its globs decide them. The first named comes first, as the
112
+ project's name; then the shortest names, which is what the graph keeps as its roots."""
113
+ libraries = library_names()
114
+ chosen = names or libraries
115
+ modules = []
116
+ for name in chosen:
117
+ if name in libraries:
118
+ listed = subprocess.run(["lake", "query", f"{name}:modules"], stdout=subprocess.PIPE, text=True)
119
+ if listed.returncode:
120
+ fail(f"Lake couldn't list the modules of {name}; see above.")
121
+ modules += listed.stdout.split()
122
+ else:
123
+ modules.append(name)
124
+ if not modules:
125
+ fail("found no modules to review. Name them, as in `lean-vouch MyProject`.")
126
+ return sorted(set(modules), key=lambda m: (m != chosen[0], m.count("."), m))
127
+
128
+
129
+ def tops(imports):
130
+ """The modules no other module imports. doc-gen4 documents what a module imports, once per
131
+ module named, so these are enough, and a library with one root takes one pass."""
132
+ imported = {module for names in imports.values() for module in names}
133
+ return [module for module in imports if module not in imported]
134
+
135
+
136
+ def packages_in(manifest):
137
+ return {p["name"].strip("«»") for p in json.loads(manifest.read_text(encoding="utf-8")).get("packages", [])} if manifest.exists() else set()
138
+
139
+
140
+ def clear_doc_packages(manifest):
141
+ """Delete the builds of the packages only doc-gen4 needs. Lake keeps a package's compiled C when
142
+ the Lean version changes, and it won't link against another Lean. The folder is shared, so what
143
+ the project itself requires stays: that may be all of Mathlib."""
144
+ for name in packages_in(manifest) - packages_in(Path("lake-manifest.json")):
145
+ shutil.rmtree(Path(".lake/packages") / name / ".lake/build", ignore_errors=True)
146
+
147
+
148
+ def set_up_folders(tag, package):
149
+ """Docs live in their own folder, so the project gains no dependency. doc-gen4 tags match Lean's
150
+ versions, and it must match the project's: its main branch needs the newest Lean."""
151
+ docbuild = HOME / "docbuild"
152
+ docbuild.mkdir(parents=True, exist_ok=True)
153
+ wanted, written = Path("lean-toolchain").read_text(encoding="utf-8"), docbuild / "lean-toolchain"
154
+ new = not (docbuild / "lakefile.toml").exists()
155
+ changed = new or not written.exists() or written.read_text(encoding="utf-8") != wanted
156
+ if changed:
157
+ (docbuild / "lakefile.toml").write_text(DOCBUILD.format(version=tag, package=package), encoding="utf-8")
158
+ written.write_text(wanted, encoding="utf-8")
159
+ # docbuild shares the project's .lake, so on another Lean it would overwrite the project's build.
160
+ clear_doc_packages(docbuild / "lake-manifest.json")
161
+ (docbuild / "lake-manifest.json").unlink(missing_ok=True)
162
+ if not (HOME / ".gitignore").exists(): # it's yours to edit, to keep marks out of git
163
+ (HOME / ".gitignore").write_text(GITIGNORE, encoding="utf-8")
164
+ if new:
165
+ print(f"lean-vouch: created {HOME}/ (doc-gen4 {tag}).", flush=True)
166
+ elif changed:
167
+ print(f"lean-vouch: moved to doc-gen4 {tag}, for the project's Lean.", flush=True)
168
+
169
+
170
+ def source_folders():
171
+ """Where the project's modules are, as a library's `srcDir` can move them: Lake's source path,
172
+ without its dependencies' folders and Lean's own."""
173
+ path = subprocess.run(["lake", "env", "printenv", "LEAN_SRC_PATH"], capture_output=True, text=True).stdout
174
+ root = Path.cwd().resolve()
175
+ folders = dict.fromkeys(Path(folder).resolve() for folder in path.strip().split(os.pathsep) if folder)
176
+ return [folder for folder in folders
177
+ if folder.is_relative_to(root) and ".lake" not in folder.relative_to(root).parts] or [root]
178
+
179
+
180
+ def run(command, cwd=".", env=None):
181
+ if subprocess.run(command, cwd=cwd, env={**os.environ, **(env or {})}).returncode:
182
+ fail(f"`{' '.join(command)}` failed; see above.")
183
+
184
+
185
+ def follow_project_pins(docbuild):
186
+ """docbuild's manifest pins the project's dependencies, and they share a folder, so an old one
187
+ would check them out again at their old revisions. After the project's build, which may be what
188
+ writes the project's manifest."""
189
+ project = Path("lake-manifest.json")
190
+ pins = hashlib.sha256(project.read_bytes() if project.exists() else b"").hexdigest()
191
+ seen = docbuild / "project-manifest.sha256"
192
+ if not seen.exists() or seen.read_text(encoding="utf-8") != pins:
193
+ (docbuild / "lake-manifest.json").unlink(missing_ok=True)
194
+ seen.write_text(pins, encoding="utf-8")
195
+
196
+
197
+ def build_docs(modules):
198
+ docbuild = HOME / "docbuild"
199
+ follow_project_pins(docbuild)
200
+ if not (docbuild / "lake-manifest.json").exists():
201
+ # Keeps Mathlib's update hook from fetching its cache again.
202
+ run(["lake", "update", "doc-gen4"], docbuild, {"MATHLIB_NO_CACHE_ON_UPDATE": "1"})
203
+ if not (docbuild / ".lake/build/doc").exists():
204
+ uses_mathlib = '"mathlib"' in Path("lake-manifest.json").read_text(encoding="utf-8") if Path("lake-manifest.json").exists() else False
205
+ print("lean-vouch: first docs build. doc-gen4 documents everything the project imports" +
206
+ (", including Mathlib: expect about half an hour and 2 GB. It doesn't recompile Mathlib."
207
+ if uses_mathlib else ": expect several minutes."), flush=True)
208
+ run(["lake", "build", *[f"+{module}:docs" for module in modules]], docbuild,
209
+ {"DOCGEN_SRC": os.environ.get("DOCGEN_SRC", "file")})
210
+
211
+
212
+ def build_graph(modules):
213
+ """Build the graph beside the current one, and return it. It goes to a file, since a project's
214
+ initializers can print to stdout, where Lean also puts compile errors: show those on failure."""
215
+ new = HOME / "graph.json.new"
216
+ done = subprocess.run(["lake", "env", "lean", "--run", str(HERE / "graph/DeclGraph.lean"), *modules],
217
+ stdout=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
218
+ env={**os.environ, "LEAN_VOUCH_GRAPH": str(new.resolve())})
219
+ if done.returncode:
220
+ new.unlink(missing_ok=True)
221
+ fail(f"building the declaration graph failed:\n{done.stdout[-4000:]}\n"
222
+ "If two libraries define the same name, they can't be imported together: review one at a "
223
+ "time, as in `lean-vouch MyLibrary`.\n"
224
+ "If this is about Lean itself, not your project, please open an issue.")
225
+ try:
226
+ return json.loads(new.read_text(encoding="utf-8"))
227
+ except (OSError, ValueError):
228
+ new.unlink(missing_ok=True)
229
+ fail("the declaration graph came out unreadable. Please open an issue.")
230
+
231
+
232
+ def slug(name):
233
+ """A name as plain lowercase words, which every filesystem and git agree on."""
234
+ plain = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
235
+ return re.sub(r"[^a-z0-9]+", "-", plain.lower()).strip("-")
236
+
237
+
238
+ def git_config(key):
239
+ try:
240
+ return subprocess.run(["git", "config", key], capture_output=True, text=True).stdout.strip()
241
+ except OSError:
242
+ return ""
243
+
244
+
245
+ def identity(chosen_id, chosen_name):
246
+ """Who's reviewing. The id tells two people of the same name apart by their email, without
247
+ publishing it, and comes out the same on each of their machines."""
248
+ git_name = git_config("user.name")
249
+ name = chosen_name or git_name or chosen_id or "reviewer"
250
+ if chosen_id:
251
+ if not ID.fullmatch(chosen_id):
252
+ fail(f"{chosen_id} can't be an id: use lowercase letters, digits and hyphens.")
253
+ return chosen_id, name
254
+ # Not from --name if there's a git name: that can change without making you someone else.
255
+ base = slug(git_name) or slug(name)
256
+ if not base:
257
+ fail(f"can't make a file name from {name!r}. Choose one with --id, such as --id my-name.")
258
+ return f"{base}-{hashlib.sha256(git_config('user.email').encode()).hexdigest()[:4]}", name
259
+
260
+
261
+ def clean_docs(docs, root, modules):
262
+ """Delete the project's pages for modules that no longer exist, and say which. doc-gen4 only
263
+ writes pages, so a renamed module's old page stays, and can still be reached from an old link."""
264
+ folder = docs / root.replace(".", "/")
265
+ stale = [page for page in folder.glob("**/*.html")
266
+ if ".".join(page.relative_to(docs).with_suffix("").parts) not in modules]
267
+ for page in stale:
268
+ page.unlink()
269
+ for empty in sorted((p for p in folder.glob("**/*") if p.is_dir()), reverse=True):
270
+ if not any(empty.iterdir()):
271
+ empty.rmdir()
272
+ if stale:
273
+ print(f"lean-vouch: removed {len(stale)} old pages: " + ", ".join(sorted(p.stem for p in stale)[:8]) +
274
+ (", ..." if len(stale) > 8 else ""), flush=True)
275
+
276
+
277
+ def report(state, **details):
278
+ """Say how a build is going, for a server and the pages open on it. The time of the last good
279
+ build carries over a failure, since those pages still show it."""
280
+ status = HOME / "build.json"
281
+ try:
282
+ built = json.loads(status.read_text(encoding="utf-8")).get("built")
283
+ except (OSError, ValueError, AttributeError):
284
+ built = None
285
+ # Swapped in whole, since the server reads it while this runs.
286
+ temporary = status.with_name(status.name + ".tmp")
287
+ temporary.write_text(json.dumps({"built": built, **details, "state": state, "pid": os.getpid()}), encoding="utf-8")
288
+ os.replace(temporary, status)
289
+
290
+
291
+ def reason(error):
292
+ """The first line of what stopped a build. Ctrl-C has nothing to say."""
293
+ return (str(getattr(error, "code", error)).splitlines() or ["The build was interrupted."])[0]
294
+
295
+
296
+ def running_server(port):
297
+ """The marks folder of the lean-vouch server on this port, if there is one."""
298
+ try:
299
+ with urlopen(f"http://127.0.0.1:{port}/review/status", timeout=1) as response:
300
+ found = json.load(response)
301
+ except (OSError, ValueError):
302
+ return None
303
+ return found.get("marks_folder") if isinstance(found, dict) else None
304
+
305
+
306
+ def main():
307
+ parser = argparse.ArgumentParser(prog="lean-vouch", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
308
+ parser.add_argument("--version", action="version", version=f"lean-vouch {VERSION}")
309
+ parser.add_argument("names", nargs="*", metavar="NAME", help="libraries or modules to review (default: every library)")
310
+ parser.add_argument("--port", type=int, default=8797)
311
+ parser.add_argument("--name", help="the name to show on your marks and notes (default: your git name)")
312
+ parser.add_argument("--id", help="who you are, as the name of your marks file (default: from your git name and email)")
313
+ parser.add_argument("--rebuild", action="store_true", help="rebuild the docs and graph, without starting a server")
314
+ parser.add_argument("--clean", action="store_true", help="delete docs pages left by renamed or removed modules")
315
+ args = parser.parse_args()
316
+ package = package_name()
317
+ tag, version = toolchain()
318
+ check_version(tag, version)
319
+ if not shutil.which("lake"):
320
+ fail("Lake isn't on your PATH. Install Lean through elan: https://github.com/leanprover/elan")
321
+ who, name = identity(args.id, args.name)
322
+ others = sorted(path.stem for path in (HOME / "marks").glob("*.json") if path.stem != who)
323
+ if others and not (HOME / "marks" / f"{who}.json").exists():
324
+ print(f"lean-vouch: there's no marks file for {who} yet, only for {', '.join(others)}. "
325
+ "If one of those is you, use --id.", flush=True)
326
+ serving = None if args.rebuild else running_server(args.port)
327
+ if serving and Path(serving) != (HOME / "marks").resolve():
328
+ fail(f"port {args.port} is serving another project. Choose another with --port.")
329
+ if serving:
330
+ print(f"lean-vouch: already serving on port {args.port}, so this only rebuilds; open pages will offer to reload.", flush=True)
331
+ set_up_folders(tag, package)
332
+ report("building")
333
+ try:
334
+ modules = modules_of(args.names)
335
+ print(f"lean-vouch: reviewing {len(modules)} modules.", flush=True)
336
+ run(["lake", "build", *[f"+{module}" for module in modules]])
337
+ built = build_graph(modules)
338
+ build_docs(tops(built["modules"]))
339
+ if args.clean:
340
+ for root in built["roots"]:
341
+ clean_docs(HOME / "docbuild/.lake/build/doc", root, built["modules"])
342
+ # Last, so a failure leaves the graph that matches the docs.
343
+ (HOME / "graph.json.new").replace(HOME / "graph.json")
344
+ except KeyboardInterrupt as error:
345
+ report("failed", message=reason(error))
346
+ print("\nlean-vouch: stopped.", file=sys.stderr)
347
+ sys.exit(130)
348
+ except (SystemExit, Exception) as error:
349
+ report("failed", message=reason(error))
350
+ raise
351
+ report("done", built=datetime.now().isoformat(timespec="seconds"))
352
+ if args.rebuild or serving:
353
+ return
354
+ command = [sys.executable, str(HERE / "server.py"), "--docs", str(HOME / "docbuild/.lake/build/doc"),
355
+ "--marks", str(HOME / "marks"), "--graph", str(HOME / "graph.json"),
356
+ *[option for folder in source_folders() for option in ("--source", str(folder))],
357
+ "--prefix", modules[0], "--id", who, "--name", name, "--port", str(args.port)]
358
+ os.execv(sys.executable, command)
359
+
360
+
361
+ if __name__ == "__main__":
362
+ main()