sllmwiki 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of sllmwiki might be problematic. Click here for more details.
- sllmwiki/__init__.py +8 -0
- sllmwiki/__main__.py +7 -0
- sllmwiki/cli.py +31 -0
- sllmwiki/commands/__init__.py +1 -0
- sllmwiki/commands/book.py +371 -0
- sllmwiki/commands/check_upstream.py +112 -0
- sllmwiki/commands/cited_sections.py +96 -0
- sllmwiki/commands/contributors.py +204 -0
- sllmwiki/commands/doctor.py +100 -0
- sllmwiki/commands/fetch_source.py +195 -0
- sllmwiki/commands/finish_narrative.py +163 -0
- sllmwiki/commands/init.py +396 -0
- sllmwiki/commands/manifest.py +110 -0
- sllmwiki/commands/narrative_format.py +178 -0
- sllmwiki/commands/publish.py +412 -0
- sllmwiki/commands/reading_plan.py +91 -0
- sllmwiki/commands/release.py +219 -0
- sllmwiki/commands/review_checklist.py +60 -0
- sllmwiki/commands/search.py +88 -0
- sllmwiki/commands/tag_tree.py +72 -0
- sllmwiki/commands/wiki_check.py +191 -0
- sllmwiki/lib/__init__.py +1 -0
- sllmwiki/lib/book.py +624 -0
- sllmwiki/lib/bookbuild.py +328 -0
- sllmwiki/lib/citations.py +135 -0
- sllmwiki/lib/compat.py +397 -0
- sllmwiki/lib/config.py +215 -0
- sllmwiki/lib/convert.py +58 -0
- sllmwiki/lib/epub.py +281 -0
- sllmwiki/lib/fetch.py +115 -0
- sllmwiki/lib/figures.py +216 -0
- sllmwiki/lib/footer.py +28 -0
- sllmwiki/lib/gitrepo.py +26 -0
- sllmwiki/lib/html2md.py +281 -0
- sllmwiki/lib/language.py +184 -0
- sllmwiki/lib/layout.py +94 -0
- sllmwiki/lib/manifest.py +164 -0
- sllmwiki/lib/markers.py +171 -0
- sllmwiki/lib/mdlinks.py +46 -0
- sllmwiki/lib/metadata.py +138 -0
- sllmwiki/lib/narrativecheck.py +200 -0
- sllmwiki/lib/numbering.py +129 -0
- sllmwiki/lib/pdf.py +712 -0
- sllmwiki/lib/plaintext.py +110 -0
- sllmwiki/lib/policy.py +80 -0
- sllmwiki/lib/quotes.py +95 -0
- sllmwiki/lib/readingplan.py +103 -0
- sllmwiki/lib/release.py +107 -0
- sllmwiki/lib/runstats.py +115 -0
- sllmwiki/lib/schema.py +161 -0
- sllmwiki/lib/search.py +125 -0
- sllmwiki/lib/tagpages.py +60 -0
- sllmwiki/lib/tagtree.py +84 -0
- sllmwiki/lib/templates.py +198 -0
- sllmwiki/lib/tex.py +261 -0
- sllmwiki/lib/toml.py +177 -0
- sllmwiki/lib/util.py +106 -0
- sllmwiki/lib/wikiindex.py +101 -0
- sllmwiki/lib/wikiyaml.py +280 -0
- sllmwiki/lib/xrefs.py +81 -0
- sllmwiki/templates/book/_static/book-html.css +52 -0
- sllmwiki/templates/book/_static/book.css +64 -0
- sllmwiki/templates/book/conf.py +48 -0
- sllmwiki/templates/languages/en.md +124 -0
- sllmwiki/templates/languages/zh-Hans.md +119 -0
- sllmwiki/templates/narratives/.markdownlint.jsonc +6 -0
- sllmwiki/templates/narratives/case_study/guide.md +47 -0
- sllmwiki/templates/narratives/case_study/template.md +42 -0
- sllmwiki/templates/narratives/comparison/guide.md +42 -0
- sllmwiki/templates/narratives/comparison/template.md +40 -0
- sllmwiki/templates/narratives/concept_brief_overview/guide.md +45 -0
- sllmwiki/templates/narratives/concept_brief_overview/template.md +32 -0
- sllmwiki/templates/narratives/shared-guide.md +48 -0
- sllmwiki/templates/narratives/shared-skeleton.md +51 -0
- sllmwiki/templates/narratives/shared.md +39 -0
- sllmwiki/templates/narratives/survey/guide.md +41 -0
- sllmwiki/templates/narratives/survey/template.md +38 -0
- sllmwiki/templates/narratives/technical_brief_overview/guide.md +49 -0
- sllmwiki/templates/narratives/technical_brief_overview/template.md +42 -0
- sllmwiki/templates/release-book.yml +25 -0
- sllmwiki/templates/review-checklist.yml +17 -0
- sllmwiki/templates/wiki/.claude/settings.json +13 -0
- sllmwiki/templates/wiki/AGENTS.md +12 -0
- sllmwiki/templates/wiki/CLAUDE.md +1 -0
- sllmwiki/templates/wiki/Config.toml +27 -0
- sllmwiki/templates/wiki/README.md +26 -0
- sllmwiki/templates/wiki/gitignore +5 -0
- sllmwiki/templates/wiki/sllmwiki/.obsidian/graph.json +15 -0
- sllmwiki/templates/wiki-checks.yml +17 -0
- sllmwiki-1.2.0.dist-info/METADATA +141 -0
- sllmwiki-1.2.0.dist-info/RECORD +96 -0
- sllmwiki-1.2.0.dist-info/WHEEL +5 -0
- sllmwiki-1.2.0.dist-info/entry_points.txt +2 -0
- sllmwiki-1.2.0.dist-info/licenses/LICENSE +201 -0
- sllmwiki-1.2.0.dist-info/licenses/NOTICE +4 -0
- sllmwiki-1.2.0.dist-info/top_level.txt +1 -0
sllmwiki/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""sllmwiki: the Structural LLM Wiki's code. The plugin's instructions run it as
|
|
2
|
+
`sllmwiki <command>`; a wiki's GitHub Actions run it from this repository at the tag."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
__version__ = '1.2.0' # the plugin's version too (.claude-plugin/plugin.json); a test keeps them equal
|
|
7
|
+
|
|
8
|
+
TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates') # what init installs, the narrative templates, the book's
|
sllmwiki/__main__.py
ADDED
sllmwiki/cli.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""sllmwiki: the Structural LLM Wiki's commands.
|
|
2
|
+
|
|
3
|
+
sllmwiki <command> [<args>] run one; sllmwiki <command> --help explains it
|
|
4
|
+
sllmwiki --version this package's version, the plugin's too
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from sllmwiki import __version__
|
|
11
|
+
from sllmwiki.lib.util import setup_stdio
|
|
12
|
+
|
|
13
|
+
COMMANDS = ('book', 'check-upstream', 'cited-sections', 'contributors', 'doctor', 'fetch-source', 'finish-narrative', 'init', 'manifest',
|
|
14
|
+
'narrative-format', 'publish', 'reading-plan', 'release', 'review-checklist', 'search', 'tag-tree', 'wiki-check')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv=None):
|
|
18
|
+
"""Run `sllmwiki <command> <args>`; its exit code. 2 for an unknown command."""
|
|
19
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
20
|
+
setup_stdio()
|
|
21
|
+
if argv[:1] == ['--version']:
|
|
22
|
+
print(f'sllmwiki {__version__}')
|
|
23
|
+
return 0
|
|
24
|
+
if not argv or argv[0] in ('-h', '--help'):
|
|
25
|
+
print(__doc__.strip() + '\n\ncommands: ' + ', '.join(COMMANDS))
|
|
26
|
+
return 0 if argv else 2
|
|
27
|
+
if argv[0] not in COMMANDS:
|
|
28
|
+
print(f"sllmwiki: no command {argv[0]!r}; the commands: {', '.join(COMMANDS)}", file=sys.stderr)
|
|
29
|
+
return 2
|
|
30
|
+
module = importlib.import_module('sllmwiki.commands.' + argv[0].replace('-', '_'))
|
|
31
|
+
return module.main(argv[1:])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""One module per `sllmwiki <command>`, each with main(argv)."""
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""book: the wiki as a book (docs/book_design.md).
|
|
3
|
+
|
|
4
|
+
sllmwiki book stage OUT [--name N] [--version V] [--date YYYY-MM-DD] [--commit C] [--source URL]
|
|
5
|
+
Writes the book as plain Markdown for Sphinx into the folder OUT, which
|
|
6
|
+
must be empty or not exist yet: index.md (the cover, the front page and
|
|
7
|
+
the contents), parts/<tag>.md, chapters/<slug>.md with their figures,
|
|
8
|
+
and the appendices (sources, glossary, open questions). Also writes
|
|
9
|
+
OUT/book.json: the parts, the chapter order, every anchor, every diagram
|
|
10
|
+
and formula to render, and the problems found. The wiki is only read.
|
|
11
|
+
--name (the files' name) defaults to the wiki's folder name, --version
|
|
12
|
+
to "preview", --date to today, --commit to the current commit and
|
|
13
|
+
--source (the wiki's web page, which the cover links to) to its origin
|
|
14
|
+
remote's. The cover's title and the author are Config.toml's title and
|
|
15
|
+
author ([structural-llm-wiki-release]), by default the name and the title.
|
|
16
|
+
Problems: BOOK_LINK (a link or anchor the book can't resolve),
|
|
17
|
+
BOOK_CYCLE (narratives built on each other in a cycle), BOOK_FIGURE (a
|
|
18
|
+
figure that isn't there). Exit code 1 when there are any.
|
|
19
|
+
sllmwiki book render STAGED --vendor DIR [--chrome PATH] [--cache DIR]
|
|
20
|
+
Draws every diagram and formula book.json lists as SVG, in headless
|
|
21
|
+
Chrome with the mermaid.js and MathJax in DIR (mermaid.min.js,
|
|
22
|
+
tex-svg-full.js), and puts the SVG in the staged pages in their place.
|
|
23
|
+
--chrome defaults to $CHROME, else the Chrome on PATH; --cache keeps
|
|
24
|
+
each SVG by its source and the libraries, so a rerun draws only what
|
|
25
|
+
changed. One that fails to draw stays as written and is a warning,
|
|
26
|
+
BOOK_RENDER, kept in book.json's warnings.
|
|
27
|
+
sllmwiki book build OUT --vendor DIR [--formats pdf,epub,html] [--chrome PATH] [--cache DIR]
|
|
28
|
+
[--sphinx CMD] [--name N] [--version V] [--date D] [--commit C] [--source URL]
|
|
29
|
+
stage into OUT/staged, render, then Sphinx (--sphinx, default
|
|
30
|
+
sphinx-build, with MyST-Parser) with the package's templates/book/
|
|
31
|
+
settings, and writes OUT/<name>-<version>.html: one file with every
|
|
32
|
+
stylesheet, image and diagram in it; for pdf, Chrome prints that file
|
|
33
|
+
to OUT/<name>-<version>.pdf (A4, page numbers, the title as the running
|
|
34
|
+
head); for epub, Sphinx's EPUB 3 to OUT/<name>-<version>.epub, from the
|
|
35
|
+
same rendered pages. --formats (pdf, epub, html) defaults to the ones
|
|
36
|
+
Config.toml's [structural-llm-wiki-release] turns on; with none, the
|
|
37
|
+
book is staged and no file is written. Problems and warnings are listed
|
|
38
|
+
(Sphinx's are BOOK_SPHINX) and don't stop the book; exit code 1 when a
|
|
39
|
+
format fails.
|
|
40
|
+
sllmwiki book parse --vendor DIR [--base REF] [--chrome PATH]
|
|
41
|
+
Checks that each diagram and formula parses, as GitHub draws them, with
|
|
42
|
+
the same mermaid.js (mermaid.parse) and MathJax the book uses: with
|
|
43
|
+
--base, only those a change brings, in the narratives changed since REF
|
|
44
|
+
(a pull request's base), and not in REF's version of them; without it,
|
|
45
|
+
every one. Reports DIAGRAM_SYNTAX and MATH_SYNTAX with the file, the
|
|
46
|
+
line and the parser's message (exit code 1). Nothing to check needs no
|
|
47
|
+
Chrome.
|
|
48
|
+
sllmwiki book issues STAGED
|
|
49
|
+
The release notes' "Known issues" section for a built book: its
|
|
50
|
+
problems, warnings and failed formats from book.json, in the wiki's
|
|
51
|
+
language; nothing when there are none.
|
|
52
|
+
|
|
53
|
+
Options: --json, --root <wiki>.
|
|
54
|
+
Exit code: as above; 2 on a usage error.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
import argparse
|
|
58
|
+
import datetime
|
|
59
|
+
import json
|
|
60
|
+
import os
|
|
61
|
+
import shlex
|
|
62
|
+
import shutil
|
|
63
|
+
import subprocess
|
|
64
|
+
import sys
|
|
65
|
+
import tempfile
|
|
66
|
+
|
|
67
|
+
sys.dont_write_bytecode = True
|
|
68
|
+
|
|
69
|
+
from sllmwiki import TEMPLATES # noqa: E402
|
|
70
|
+
from sllmwiki.lib import book, bookbuild, compat, config, language, layout # noqa: E402
|
|
71
|
+
from sllmwiki.lib.layout import Cli, entry # noqa: E402
|
|
72
|
+
from sllmwiki.lib.util import dumps, write_text # noqa: E402
|
|
73
|
+
|
|
74
|
+
cli = Cli('sllmwiki book', 'usage: sllmwiki book stage|render|build ... (sllmwiki book --help)')
|
|
75
|
+
FORMATS = ('pdf', 'epub', 'html') # what build makes, as Config.toml's <format>_book names it
|
|
76
|
+
TEMPLATE = os.path.join(TEMPLATES, 'book')
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _git(root, *args):
|
|
80
|
+
"""git's output in root, or '' when it fails."""
|
|
81
|
+
try:
|
|
82
|
+
r = subprocess.run(['git', '-C', root, *args], capture_output=True, text=True, check=False)
|
|
83
|
+
except OSError: # pragma: no cover (no git)
|
|
84
|
+
return ''
|
|
85
|
+
return r.stdout.strip() if r.returncode == 0 else ''
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _empty(cli_out):
|
|
89
|
+
out = os.path.abspath(cli_out)
|
|
90
|
+
if os.path.exists(out) and (not os.path.isdir(out) or os.listdir(out)):
|
|
91
|
+
cli.usage(f'{cli_out} must be an empty folder, or not exist yet')
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def stage(root, out, args):
|
|
96
|
+
"""Write the staged book into out; its book.json."""
|
|
97
|
+
release = config.get(root).release
|
|
98
|
+
staged = book.stage(root, args.name or os.path.basename(root), args.version, args.date,
|
|
99
|
+
_git(root, 'rev-parse', '--short', 'HEAD') if args.commit is None else args.commit,
|
|
100
|
+
book.web_url(_git(root, 'remote', 'get-url', 'origin')) if args.source is None else args.source or None,
|
|
101
|
+
release['title'] or None, release['author'] or None)
|
|
102
|
+
for rel, text in staged.files.items():
|
|
103
|
+
write_text(os.path.join(out, rel), text)
|
|
104
|
+
for fig in staged.figures:
|
|
105
|
+
dest = os.path.join(out, 'chapters', fig)
|
|
106
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
107
|
+
shutil.copyfile(os.path.join(root, layout.NARRATIVES, fig), dest)
|
|
108
|
+
_save(out, staged.manifest)
|
|
109
|
+
return staged.manifest
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _save(staged, data):
|
|
113
|
+
write_text(os.path.join(staged, 'book.json'), json.dumps(data, indent=1, ensure_ascii=False) + '\n')
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def render(staged, vendor, chrome, cache):
|
|
117
|
+
"""Draw book.json's diagrams and formulas and put them in the staged pages; book.json, with its warnings."""
|
|
118
|
+
with open(os.path.join(staged, 'book.json'), encoding='utf-8') as f:
|
|
119
|
+
data = json.load(f)
|
|
120
|
+
items = {r['id']: r for r in data['renders']}
|
|
121
|
+
libs = bookbuild.libraries(vendor)
|
|
122
|
+
svgs = {}
|
|
123
|
+
for rid in items:
|
|
124
|
+
path = os.path.join(cache, f'{rid}-{libs}.svg') if cache else None
|
|
125
|
+
if path and os.path.isfile(path):
|
|
126
|
+
with open(path, encoding='utf-8') as f:
|
|
127
|
+
svgs[rid] = f.read()
|
|
128
|
+
todo = [{'id': r['id'], 'kind': r['kind'], 'source': r['source']} for rid, r in items.items() if rid not in svgs]
|
|
129
|
+
if todo:
|
|
130
|
+
found = bookbuild.find_chrome(chrome)
|
|
131
|
+
if not found:
|
|
132
|
+
cli.fail('no Chrome found: pass --chrome or set $CHROME')
|
|
133
|
+
try:
|
|
134
|
+
drawn = bookbuild.draw(todo, vendor, found, staged)
|
|
135
|
+
except ValueError as e:
|
|
136
|
+
cli.fail(str(e))
|
|
137
|
+
for rid, svg in drawn.items():
|
|
138
|
+
if isinstance(svg, str):
|
|
139
|
+
svgs[rid] = bookbuild.clean(svg, items[rid]['source'])
|
|
140
|
+
if cache:
|
|
141
|
+
write_text(os.path.join(cache, f'{rid}-{libs}.svg'), svgs[rid])
|
|
142
|
+
else:
|
|
143
|
+
svgs[rid] = svg
|
|
144
|
+
warnings = [w for w in data.get('warnings', []) if w['code'] != 'BOOK_RENDER']
|
|
145
|
+
for rid, svg in svgs.items():
|
|
146
|
+
if not isinstance(svg, str):
|
|
147
|
+
r = items[rid]
|
|
148
|
+
warnings.append({'code': 'BOOK_RENDER', 'where': r['files'][0],
|
|
149
|
+
'message': f'a {r["kind"]} that doesn\'t draw stays as written: {svg["error"]}: {r["source"][:80]}'})
|
|
150
|
+
counts = {}
|
|
151
|
+
for folder, _, files in sorted(os.walk(staged)):
|
|
152
|
+
for name in sorted(files):
|
|
153
|
+
if name.endswith('.md'):
|
|
154
|
+
path = os.path.join(folder, name)
|
|
155
|
+
with open(path, encoding='utf-8') as f:
|
|
156
|
+
text = f.read()
|
|
157
|
+
write_text(path, bookbuild.put_in(text, svgs, book.render_id, counts))
|
|
158
|
+
data['warnings'] = warnings
|
|
159
|
+
_save(staged, data)
|
|
160
|
+
return data
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _sphinx(root, out, staged, data, sphinx, builder):
|
|
164
|
+
"""Run Sphinx's builder on the rendered staged book into out/_build/<builder>: (its warnings, None) or (None, why it failed)."""
|
|
165
|
+
lang = bookbuild.sphinx_language(config.get(root).settings['language'])
|
|
166
|
+
build = os.path.join(out, '_build')
|
|
167
|
+
name = f'{data["name"]}-{data["version"]}'
|
|
168
|
+
cmd = [*shlex.split(sphinx), '-q', '-b', builder, '-c', TEMPLATE, '-d', os.path.join(build, 'doctrees'),
|
|
169
|
+
'-D', f'project={data["title"]}', '-D', f'version={data["version"]}', '-D', f'release={data["version"]}',
|
|
170
|
+
'-D', f'html_title={data["title"]} {data["version"]}', # not Sphinx's "<project> <release> documentation"
|
|
171
|
+
'-D', f'copyright={data["author"]}', '-D', f'language={lang}', '-D', f'epub_basename={name}',
|
|
172
|
+
'-D', f'epub_author={data["author"]}', '-D', 'epub_publisher=sllmwiki', staged, os.path.join(build, builder)]
|
|
173
|
+
try:
|
|
174
|
+
run = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
175
|
+
except OSError as e:
|
|
176
|
+
return None, f'{sphinx} didn\'t run: {e}'
|
|
177
|
+
if run.returncode != 0:
|
|
178
|
+
return None, f'Sphinx failed: {(run.stderr or run.stdout).strip()[-2000:]}'
|
|
179
|
+
return [line for line in run.stderr.splitlines() if 'WARNING' in line or 'ERROR' in line], None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def html_book(root, out, staged, data, sphinx):
|
|
183
|
+
"""Sphinx's single-file HTML of the rendered staged book: (path, Sphinx's warnings), or (None, why it failed)."""
|
|
184
|
+
warnings, why = _sphinx(root, out, staged, data, sphinx, 'singlehtml')
|
|
185
|
+
folder = os.path.join(out, '_build', 'singlehtml')
|
|
186
|
+
if why or not os.path.isfile(os.path.join(folder, 'index.html')):
|
|
187
|
+
return None, why or 'Sphinx wrote no index.html'
|
|
188
|
+
path = os.path.join(out, f'{data["name"]}-{data["version"]}.html')
|
|
189
|
+
write_text(path, bookbuild.self_contained(folder))
|
|
190
|
+
return path, warnings
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def epub_book(root, out, staged, data, sphinx):
|
|
194
|
+
"""Sphinx's EPUB 3 of the rendered staged book, finished (bookbuild.finish_epub): (path, Sphinx's warnings),
|
|
195
|
+
or (None, why it failed)."""
|
|
196
|
+
warnings, why = _sphinx(root, out, staged, data, sphinx, 'epub')
|
|
197
|
+
name = f'{data["name"]}-{data["version"]}'
|
|
198
|
+
built = os.path.join(out, '_build', 'epub', f'{name}.epub')
|
|
199
|
+
if why or not os.path.isfile(built):
|
|
200
|
+
return None, why or 'Sphinx wrote no EPUB'
|
|
201
|
+
path = os.path.join(out, f'{name}.epub')
|
|
202
|
+
bookbuild.finish_epub(built, path)
|
|
203
|
+
return path, warnings
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def to_parse(root, base=None):
|
|
207
|
+
"""[(file, line, kind, source)] of the diagrams and formulas to check: with base, those in narratives
|
|
208
|
+
changed since base (the working tree against it, new files git doesn't track yet included) whose kind
|
|
209
|
+
and source aren't in base's version."""
|
|
210
|
+
folder = layout.NARRATIVES
|
|
211
|
+
if base:
|
|
212
|
+
names = (_git(root, 'diff', '--name-only', base, '--', folder).split('\n')
|
|
213
|
+
+ _git(root, 'ls-files', '--others', '--exclude-standard', '--', folder).split('\n'))
|
|
214
|
+
else:
|
|
215
|
+
names = [f'{folder}/{n}' for n in sorted(os.listdir(os.path.join(root, folder)))]
|
|
216
|
+
out = []
|
|
217
|
+
for rel in names:
|
|
218
|
+
path = os.path.join(root, rel)
|
|
219
|
+
if not (rel.endswith('.md') and os.path.dirname(rel) == folder and os.path.isfile(path)):
|
|
220
|
+
continue
|
|
221
|
+
before = {(r.kind, r.source) for r in compat.renderables(_git(root, 'show', f'{base}:{rel}'))} if base else set()
|
|
222
|
+
with open(path, encoding='utf-8') as f:
|
|
223
|
+
text = f.read()
|
|
224
|
+
out += [(rel, r.first + 1, r.kind, r.source) for r in compat.renderables(text) if (r.kind, r.source) not in before]
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def parse(root, base, vendor, chrome, as_json):
|
|
229
|
+
"""Check what to_parse finds in Chrome; print the findings; the exit code."""
|
|
230
|
+
found = to_parse(root, base)
|
|
231
|
+
items, where = {}, {}
|
|
232
|
+
for rel, line, kind, source in found:
|
|
233
|
+
rid = book.render_id(kind, source)
|
|
234
|
+
items[rid] = {'id': rid, 'kind': kind, 'source': source}
|
|
235
|
+
where.setdefault(rid, []).append(f'{rel}:{line}')
|
|
236
|
+
findings = []
|
|
237
|
+
if items:
|
|
238
|
+
browser = bookbuild.find_chrome(chrome)
|
|
239
|
+
if not browser:
|
|
240
|
+
cli.fail('no Chrome found to parse the diagrams and formulas: pass --chrome or set $CHROME')
|
|
241
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
242
|
+
try:
|
|
243
|
+
results = bookbuild.draw(list(items.values()), vendor, browser, tmp, parse=True)
|
|
244
|
+
except ValueError as e:
|
|
245
|
+
cli.fail(str(e))
|
|
246
|
+
for rid, result in results.items():
|
|
247
|
+
if isinstance(result, dict):
|
|
248
|
+
code = 'DIAGRAM_SYNTAX' if items[rid]['kind'] == 'mermaid' else 'MATH_SYNTAX'
|
|
249
|
+
message = ' '.join(result['error'].split())[:300]
|
|
250
|
+
findings += [{'code': code, 'where': w, 'message': message} for w in where[rid]]
|
|
251
|
+
findings.sort(key=lambda f: f['where'])
|
|
252
|
+
if as_json:
|
|
253
|
+
print(dumps({'checked': len(found), 'findings': findings}))
|
|
254
|
+
else:
|
|
255
|
+
for f in findings:
|
|
256
|
+
print(f'error {f["code"]:<15} {f["where"]}: {f["message"]}')
|
|
257
|
+
print(f'parse: {len(found)} diagram(s) and formula(s) checked, {len(findings)} error(s)')
|
|
258
|
+
return 1 if findings else 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def main(argv):
|
|
262
|
+
p = argparse.ArgumentParser(prog='sllmwiki book', description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
263
|
+
p.add_argument('command', choices=['stage', 'render', 'build', 'issues', 'parse'])
|
|
264
|
+
p.add_argument('out', nargs='?')
|
|
265
|
+
p.add_argument('--base')
|
|
266
|
+
p.add_argument('--name')
|
|
267
|
+
p.add_argument('--version', default='preview')
|
|
268
|
+
p.add_argument('--date', default=datetime.date.today().isoformat())
|
|
269
|
+
p.add_argument('--commit')
|
|
270
|
+
p.add_argument('--source')
|
|
271
|
+
p.add_argument('--formats', type=lambda v: [f.strip() for f in v.split(',') if f.strip()])
|
|
272
|
+
p.add_argument('--vendor')
|
|
273
|
+
p.add_argument('--chrome')
|
|
274
|
+
p.add_argument('--cache')
|
|
275
|
+
p.add_argument('--sphinx', default='sphinx-build')
|
|
276
|
+
p.add_argument('--json', action='store_true')
|
|
277
|
+
p.add_argument('--root')
|
|
278
|
+
args = p.parse_args(argv)
|
|
279
|
+
if args.command == 'parse':
|
|
280
|
+
args.vendor = bookbuild.unpacked(args.vendor)
|
|
281
|
+
if not args.vendor:
|
|
282
|
+
cli.usage(f'parse needs --vendor DIR holding {bookbuild.MERMAID} and {bookbuild.MATHJAX} (either may be kept in numbered parts)')
|
|
283
|
+
return parse(cli.wiki(args.root), args.base, args.vendor, args.chrome, args.json)
|
|
284
|
+
if not args.out:
|
|
285
|
+
cli.usage(f'{args.command} needs a folder')
|
|
286
|
+
if args.command in ('render', 'build'):
|
|
287
|
+
args.vendor = bookbuild.unpacked(args.vendor)
|
|
288
|
+
if not args.vendor:
|
|
289
|
+
cli.usage(f'{args.command} needs --vendor DIR holding {bookbuild.MERMAID} and {bookbuild.MATHJAX} (either may be kept in numbered parts)')
|
|
290
|
+
unknown = [f for f in args.formats or [] if f not in FORMATS]
|
|
291
|
+
if unknown or args.formats == []:
|
|
292
|
+
cli.usage(f'--formats can be {", ".join(FORMATS)}, not {", ".join(unknown) or "nothing"}')
|
|
293
|
+
if args.command == 'issues':
|
|
294
|
+
path = os.path.join(args.out, 'book.json')
|
|
295
|
+
if not os.path.isfile(path):
|
|
296
|
+
cli.usage(f'{args.out} isn\'t a staged book (no book.json)')
|
|
297
|
+
with open(path, encoding='utf-8') as f:
|
|
298
|
+
data = json.load(f)
|
|
299
|
+
root = cli.wiki(args.root) if args.root else None
|
|
300
|
+
text = book.issues(data, language.pack(config.get(root).settings['language']) if root else None)
|
|
301
|
+
if text:
|
|
302
|
+
print(text, end='')
|
|
303
|
+
return 0
|
|
304
|
+
if args.command == 'render':
|
|
305
|
+
staged = os.path.abspath(args.out)
|
|
306
|
+
if not os.path.isfile(os.path.join(staged, 'book.json')):
|
|
307
|
+
cli.usage(f'{args.out} isn\'t a staged book (no book.json): run sllmwiki book stage first')
|
|
308
|
+
data = render(staged, args.vendor, args.chrome, args.cache)
|
|
309
|
+
return _report(args, data, staged)
|
|
310
|
+
root = cli.wiki(args.root)
|
|
311
|
+
out = _empty(args.out)
|
|
312
|
+
if args.command == 'stage':
|
|
313
|
+
data = stage(root, out, args)
|
|
314
|
+
_report(args, data, out)
|
|
315
|
+
return 1 if data['problems'] else 0
|
|
316
|
+
staged = os.path.join(out, 'staged')
|
|
317
|
+
formats = args.formats if args.formats is not None else config.get(root).book_formats()
|
|
318
|
+
data = stage(root, staged, args)
|
|
319
|
+
data['files'], data['failed'] = {}, {}
|
|
320
|
+
if formats:
|
|
321
|
+
data = render(staged, args.vendor, args.chrome, args.cache)
|
|
322
|
+
data['files'], data['failed'] = {}, {}
|
|
323
|
+
path, extra = html_book(root, out, staged, data, args.sphinx) # the PDF is printed from it
|
|
324
|
+
if path:
|
|
325
|
+
data['warnings'] += [{'code': 'BOOK_SPHINX', 'where': 'sphinx', 'message': w} for w in extra]
|
|
326
|
+
if 'html' in formats:
|
|
327
|
+
data['files']['html'] = path
|
|
328
|
+
for fmt in formats:
|
|
329
|
+
if fmt == 'epub':
|
|
330
|
+
epub, more = epub_book(root, out, staged, data, args.sphinx)
|
|
331
|
+
if epub:
|
|
332
|
+
data['files']['epub'] = epub
|
|
333
|
+
data['warnings'] += [{'code': 'BOOK_SPHINX', 'where': 'sphinx (epub)', 'message': w} for w in more
|
|
334
|
+
if w not in extra]
|
|
335
|
+
else:
|
|
336
|
+
data['failed']['epub'] = more
|
|
337
|
+
elif not path:
|
|
338
|
+
data['failed'][fmt] = extra
|
|
339
|
+
elif fmt == 'pdf':
|
|
340
|
+
pdf = os.path.splitext(path)[0] + '.pdf'
|
|
341
|
+
chrome = bookbuild.find_chrome(args.chrome)
|
|
342
|
+
why = bookbuild.print_pdf(path, pdf, chrome, f'{{{data["title"]} 📖}}') if chrome else 'no Chrome found to print it'
|
|
343
|
+
if why:
|
|
344
|
+
data['failed']['pdf'] = why
|
|
345
|
+
else:
|
|
346
|
+
data['files']['pdf'] = pdf
|
|
347
|
+
_save(staged, data)
|
|
348
|
+
_report(args, data, out)
|
|
349
|
+
return 1 if data['failed'] else 0
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _report(args, data, out):
|
|
353
|
+
problems, warnings = data['problems'], data.get('warnings', [])
|
|
354
|
+
if args.json:
|
|
355
|
+
print(dumps({'out': out, 'chapters': len(data['order']), 'renders': len(data['renders']), 'problems': problems,
|
|
356
|
+
'warnings': warnings, 'files': data.get('files', {}), 'failed': data.get('failed', {})}))
|
|
357
|
+
return 0
|
|
358
|
+
for f in problems + warnings:
|
|
359
|
+
print(f'{f["code"]} {f["where"]}: {f["message"]}')
|
|
360
|
+
for fmt, why in data.get('failed', {}).items():
|
|
361
|
+
print(f'{fmt} failed: {why}')
|
|
362
|
+
for fmt, path in data.get('files', {}).items():
|
|
363
|
+
print(f'{fmt}: {path} ({os.path.getsize(path):,} bytes)')
|
|
364
|
+
print(f'{len(data["order"])} chapters, {len(data["renders"])} diagrams and formulas, '
|
|
365
|
+
f'{len(problems)} problem{"s" if len(problems) != 1 else ""}, '
|
|
366
|
+
f'{len(warnings)} warning{"s" if len(warnings) != 1 else ""}: {out}')
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == '__main__': # pragma: no cover
|
|
371
|
+
entry(main)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""check-upstream: has a saved source changed where it came from? (design doc §7.2)
|
|
3
|
+
|
|
4
|
+
sllmwiki check-upstream [<slug> ...] [--tags a,b] [--json] [--root <wiki>]
|
|
5
|
+
|
|
6
|
+
Fetches each saved source again (all of them, the given slugs, or those under
|
|
7
|
+
the given tags), converts it the way fetch-source saved it, and compares it
|
|
8
|
+
with the saved copy. A changed one is marked upstream_changed: <today> in the
|
|
9
|
+
sources manifest, which wiki-check then reports (SOURCE_UPSTREAM_CHANGED: re-ingest
|
|
10
|
+
it with sllmwiki fetch-source --refresh, and narratives pinned to it get rechecked).
|
|
11
|
+
An unchanged one loses any earlier mark. A source that can't be fetched or
|
|
12
|
+
converted is reported and left as it is. A long PDF saved in slices is fetched
|
|
13
|
+
once and each slice compared with the new slice at the same place: the same
|
|
14
|
+
page of a PDF, the same top-level entry of an EPUB's contents.
|
|
15
|
+
|
|
16
|
+
A saved copy also differs when the skill's converter has improved since it was
|
|
17
|
+
saved (a PDF's placeholder title dropped, say): re-ingesting takes the better copy.
|
|
18
|
+
|
|
19
|
+
It uses the network, so it runs on request, never as part of a check.
|
|
20
|
+
Exit code: 0, 1 on a refusal (no such slug or tag), 2 on a usage error.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
sys.dont_write_bytecode = True
|
|
29
|
+
|
|
30
|
+
from sllmwiki.lib import convert, fetch, layout, manifest, wikiindex # noqa: E402
|
|
31
|
+
from sllmwiki.lib.layout import Cli, entry # noqa: E402
|
|
32
|
+
from sllmwiki.lib.util import dumps, read_bytes, split_footer, today # noqa: E402
|
|
33
|
+
|
|
34
|
+
cli = Cli('sllmwiki check-upstream', 'usage: sllmwiki check-upstream [<slug> ...] [--tags a,b] [--json]')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fetch_converted(url):
|
|
38
|
+
"""(markdown bytes, slices) of the source at url, or raises FetchError or ConvertError."""
|
|
39
|
+
r = fetch.fetch(url)
|
|
40
|
+
return convert.convert(fetch.kind_of(r.content_type, r.url), r.data, r.url, r.content_type, None, r.url)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def place_of(url):
|
|
44
|
+
"""A slice's place from its URL's fragment (page=N, chapter=N), or None."""
|
|
45
|
+
_, _, frag = url.partition('#')
|
|
46
|
+
return frag if re.match(r'(page|chapter)=\d+\Z', frag) else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compare(root, entry, fetched):
|
|
50
|
+
"""(status, detail) for one saved source against what its URL serves now."""
|
|
51
|
+
base = entry['url'].split('#')[0]
|
|
52
|
+
if base not in fetched:
|
|
53
|
+
try:
|
|
54
|
+
fetched[base] = fetch_converted(base)
|
|
55
|
+
except (fetch.FetchError, convert.ConvertError) as e:
|
|
56
|
+
fetched[base] = e
|
|
57
|
+
got = fetched[base]
|
|
58
|
+
if isinstance(got, Exception):
|
|
59
|
+
return 'unreachable', str(got)
|
|
60
|
+
body, slices = got
|
|
61
|
+
place = place_of(entry['url'])
|
|
62
|
+
if place is not None:
|
|
63
|
+
match = [s for s in slices if s[1] == place]
|
|
64
|
+
if not match:
|
|
65
|
+
kind, n = place.split('=')
|
|
66
|
+
return 'changed', f'no slice starts on page {n} any more' if kind == 'page' else f'no top-level entry {n} in its contents any more'
|
|
67
|
+
body = match[0][2].encode()
|
|
68
|
+
saved = split_footer(read_bytes(os.path.join(root, layout.in_content(entry['file']))))[0]
|
|
69
|
+
if split_footer(body)[0].rstrip(b'\n') == saved.rstrip(b'\n'):
|
|
70
|
+
return 'unchanged', ''
|
|
71
|
+
return 'changed', f'{len(saved):,} bytes saved, {len(body):,} now'
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main(argv):
|
|
75
|
+
p = argparse.ArgumentParser(prog='sllmwiki check-upstream', description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
76
|
+
p.add_argument('slugs', nargs='*')
|
|
77
|
+
p.add_argument('--tags', type=lambda v: [t.strip() for t in v.split(',') if t.strip()], default=[])
|
|
78
|
+
p.add_argument('--json', action='store_true')
|
|
79
|
+
p.add_argument('--root')
|
|
80
|
+
args = p.parse_args(argv)
|
|
81
|
+
root = cli.wiki(args.root)
|
|
82
|
+
sources = manifest.load(root, 'sources')
|
|
83
|
+
unknown = [s for s in args.slugs if s not in sources] + [t for t in args.tags if t not in manifest.load(root, 'tags')]
|
|
84
|
+
if unknown:
|
|
85
|
+
cli.fail(f'no such source or tag: {", ".join(unknown)}')
|
|
86
|
+
chosen = [s for s, e in sources.items() if e.get('file') and (not args.slugs and not args.tags or s in args.slugs
|
|
87
|
+
or set(args.tags) & set(e.get('tags') or []))]
|
|
88
|
+
results, fetched, marked = [], {}, False
|
|
89
|
+
for slug in chosen:
|
|
90
|
+
e = sources[slug]
|
|
91
|
+
status, detail = compare(root, e, fetched)
|
|
92
|
+
if status == 'changed' and not e.get('upstream_changed'):
|
|
93
|
+
manifest.save(root, 'sources', slug, dict(e, upstream_changed=today()))
|
|
94
|
+
marked = True
|
|
95
|
+
elif status == 'unchanged' and e.get('upstream_changed'):
|
|
96
|
+
manifest.save(root, 'sources', slug, {k: v for k, v in e.items() if k != 'upstream_changed'})
|
|
97
|
+
marked = True
|
|
98
|
+
results.append({'slug': slug, 'status': status, 'detail': detail})
|
|
99
|
+
if marked:
|
|
100
|
+
wikiindex.sync(root, write=True)
|
|
101
|
+
if args.json:
|
|
102
|
+
print(dumps({'sources': results}))
|
|
103
|
+
return 0
|
|
104
|
+
for r in results:
|
|
105
|
+
print(f'{r["status"]:<12} {r["slug"]}' + (f': {r["detail"]}' if r['detail'] else ''))
|
|
106
|
+
changed = [r['slug'] for r in results if r['status'] == 'changed']
|
|
107
|
+
print(f'{len(results)} checked, {len(changed)} changed' + (f': re-ingest with sllmwiki fetch-source --refresh ({", ".join(changed)})' if changed else ''))
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == '__main__': # pragma: no cover
|
|
112
|
+
entry(main)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cited-sections: each section a narrative cites, once, with the claims that cite it
|
|
3
|
+
(design doc §7.3), so lint reads those sections and not whole sources.
|
|
4
|
+
|
|
5
|
+
sllmwiki cited-sections <narrative> [--claims-only] [--json] [--root <wiki>]
|
|
6
|
+
|
|
7
|
+
<narrative> is its slug or its path. For each cited target, in order of first
|
|
8
|
+
citation: the target, the claims citing it (the sentence before each citation,
|
|
9
|
+
with its line), then the section's text (a code source's lines, numbered).
|
|
10
|
+
--claims-only leaves the sections' text out. A target that doesn't resolve is
|
|
11
|
+
listed with the reason; wiki-check reports it too.
|
|
12
|
+
|
|
13
|
+
Exit code: 0, 1 when the narrative isn't found, 2 on a usage error.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
sys.dont_write_bytecode = True
|
|
22
|
+
|
|
23
|
+
from sllmwiki.lib import citations, layout, search # noqa: E402
|
|
24
|
+
from sllmwiki.lib.layout import Cli, entry # noqa: E402
|
|
25
|
+
from sllmwiki.lib.util import dumps, read_text # noqa: E402
|
|
26
|
+
|
|
27
|
+
cli = Cli('sllmwiki cited-sections', 'usage: sllmwiki cited-sections <narrative> [--claims-only] [--json]')
|
|
28
|
+
_LINKS = re.compile(r'\[\\\[[0-9]+\\\]\]\([^)\s]*\)')
|
|
29
|
+
_SENTENCE_END = re.compile(r'(?<=[.!?:;])[*_]*\s+(?=\S)|(?<=[。!?;:])[*_]*\s*(?=\S)')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def claim(line, start):
|
|
33
|
+
"""The sentence a citation at line[start] backs: the text before it since the last sentence end."""
|
|
34
|
+
before = _LINKS.sub('', line[:start]).strip()
|
|
35
|
+
before = re.sub(r'^\s*(?:[-*+]|\d+\.|\|)\s*', '', before)
|
|
36
|
+
parts = [p for p in _SENTENCE_END.split(before) if p.strip()]
|
|
37
|
+
return parts[-1].strip(' |*,') if parts else ''
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def collect(text):
|
|
41
|
+
"""[(target, [(line, n, claim)])] in order of first citation."""
|
|
42
|
+
lines = text.split('\n')
|
|
43
|
+
found, last = {}, {}
|
|
44
|
+
for c in citations.cites(text):
|
|
45
|
+
line = lines[c.line]
|
|
46
|
+
start = line.find(f'[\\[{c.n}\\]]({c.target})')
|
|
47
|
+
said = claim(line, start) or last.get(c.line, '') # a second citation right after the first backs the same claim
|
|
48
|
+
last[c.line] = said
|
|
49
|
+
found.setdefault(c.target, []).append((c.line + 1, c.n, said))
|
|
50
|
+
return list(found.items())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv):
|
|
54
|
+
p = argparse.ArgumentParser(prog='sllmwiki cited-sections', description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
55
|
+
p.add_argument('narrative')
|
|
56
|
+
p.add_argument('--claims-only', action='store_true')
|
|
57
|
+
p.add_argument('--json', action='store_true')
|
|
58
|
+
p.add_argument('--root')
|
|
59
|
+
args = p.parse_args(argv)
|
|
60
|
+
root = cli.wiki(args.root)
|
|
61
|
+
rel, _ = search.resolve(root, args.narrative)
|
|
62
|
+
if rel is None or not rel.startswith(layout.NARRATIVES + '/'):
|
|
63
|
+
cli.fail(f'no narrative {args.narrative}')
|
|
64
|
+
text = read_text(os.path.join(root, rel))
|
|
65
|
+
out = []
|
|
66
|
+
for target, claims in collect(text):
|
|
67
|
+
path, anchor = search.resolve(root, target, base=os.path.dirname(rel))
|
|
68
|
+
section, problem = None, None
|
|
69
|
+
if path is None:
|
|
70
|
+
problem = 'no such file'
|
|
71
|
+
elif not anchor:
|
|
72
|
+
problem = 'cites the whole file, not a section'
|
|
73
|
+
else:
|
|
74
|
+
section = search.section(search.read_file(root, path), anchor)
|
|
75
|
+
problem = None if section is not None else f'{path} has no section #{anchor}'
|
|
76
|
+
out.append({'target': target, 'file': path, 'anchor': anchor, 'claims': [{'line': ln, 'n': n, 'claim': c} for ln, n, c in claims],
|
|
77
|
+
'problem': problem, 'text': None if args.claims_only else section})
|
|
78
|
+
if args.json:
|
|
79
|
+
print(dumps({'narrative': rel, 'cited': out}))
|
|
80
|
+
return 0
|
|
81
|
+
for item in out:
|
|
82
|
+
print(f'## {item["target"]}' + (f' ({item["problem"]})' if item['problem'] else ''))
|
|
83
|
+
print()
|
|
84
|
+
for c in item['claims']:
|
|
85
|
+
print(f'- line {c["line"]} [{c["n"]}]: {c["claim"]}')
|
|
86
|
+
if item['text']:
|
|
87
|
+
print()
|
|
88
|
+
print(item['text'].rstrip('\n'))
|
|
89
|
+
print()
|
|
90
|
+
if not out:
|
|
91
|
+
print(f'{rel} cites nothing')
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == '__main__': # pragma: no cover
|
|
96
|
+
entry(main)
|