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.

Files changed (96) hide show
  1. sllmwiki/__init__.py +8 -0
  2. sllmwiki/__main__.py +7 -0
  3. sllmwiki/cli.py +31 -0
  4. sllmwiki/commands/__init__.py +1 -0
  5. sllmwiki/commands/book.py +371 -0
  6. sllmwiki/commands/check_upstream.py +112 -0
  7. sllmwiki/commands/cited_sections.py +96 -0
  8. sllmwiki/commands/contributors.py +204 -0
  9. sllmwiki/commands/doctor.py +100 -0
  10. sllmwiki/commands/fetch_source.py +195 -0
  11. sllmwiki/commands/finish_narrative.py +163 -0
  12. sllmwiki/commands/init.py +396 -0
  13. sllmwiki/commands/manifest.py +110 -0
  14. sllmwiki/commands/narrative_format.py +178 -0
  15. sllmwiki/commands/publish.py +412 -0
  16. sllmwiki/commands/reading_plan.py +91 -0
  17. sllmwiki/commands/release.py +219 -0
  18. sllmwiki/commands/review_checklist.py +60 -0
  19. sllmwiki/commands/search.py +88 -0
  20. sllmwiki/commands/tag_tree.py +72 -0
  21. sllmwiki/commands/wiki_check.py +191 -0
  22. sllmwiki/lib/__init__.py +1 -0
  23. sllmwiki/lib/book.py +624 -0
  24. sllmwiki/lib/bookbuild.py +328 -0
  25. sllmwiki/lib/citations.py +135 -0
  26. sllmwiki/lib/compat.py +397 -0
  27. sllmwiki/lib/config.py +215 -0
  28. sllmwiki/lib/convert.py +58 -0
  29. sllmwiki/lib/epub.py +281 -0
  30. sllmwiki/lib/fetch.py +115 -0
  31. sllmwiki/lib/figures.py +216 -0
  32. sllmwiki/lib/footer.py +28 -0
  33. sllmwiki/lib/gitrepo.py +26 -0
  34. sllmwiki/lib/html2md.py +281 -0
  35. sllmwiki/lib/language.py +184 -0
  36. sllmwiki/lib/layout.py +94 -0
  37. sllmwiki/lib/manifest.py +164 -0
  38. sllmwiki/lib/markers.py +171 -0
  39. sllmwiki/lib/mdlinks.py +46 -0
  40. sllmwiki/lib/metadata.py +138 -0
  41. sllmwiki/lib/narrativecheck.py +200 -0
  42. sllmwiki/lib/numbering.py +129 -0
  43. sllmwiki/lib/pdf.py +712 -0
  44. sllmwiki/lib/plaintext.py +110 -0
  45. sllmwiki/lib/policy.py +80 -0
  46. sllmwiki/lib/quotes.py +95 -0
  47. sllmwiki/lib/readingplan.py +103 -0
  48. sllmwiki/lib/release.py +107 -0
  49. sllmwiki/lib/runstats.py +115 -0
  50. sllmwiki/lib/schema.py +161 -0
  51. sllmwiki/lib/search.py +125 -0
  52. sllmwiki/lib/tagpages.py +60 -0
  53. sllmwiki/lib/tagtree.py +84 -0
  54. sllmwiki/lib/templates.py +198 -0
  55. sllmwiki/lib/tex.py +261 -0
  56. sllmwiki/lib/toml.py +177 -0
  57. sllmwiki/lib/util.py +106 -0
  58. sllmwiki/lib/wikiindex.py +101 -0
  59. sllmwiki/lib/wikiyaml.py +280 -0
  60. sllmwiki/lib/xrefs.py +81 -0
  61. sllmwiki/templates/book/_static/book-html.css +52 -0
  62. sllmwiki/templates/book/_static/book.css +64 -0
  63. sllmwiki/templates/book/conf.py +48 -0
  64. sllmwiki/templates/languages/en.md +124 -0
  65. sllmwiki/templates/languages/zh-Hans.md +119 -0
  66. sllmwiki/templates/narratives/.markdownlint.jsonc +6 -0
  67. sllmwiki/templates/narratives/case_study/guide.md +47 -0
  68. sllmwiki/templates/narratives/case_study/template.md +42 -0
  69. sllmwiki/templates/narratives/comparison/guide.md +42 -0
  70. sllmwiki/templates/narratives/comparison/template.md +40 -0
  71. sllmwiki/templates/narratives/concept_brief_overview/guide.md +45 -0
  72. sllmwiki/templates/narratives/concept_brief_overview/template.md +32 -0
  73. sllmwiki/templates/narratives/shared-guide.md +48 -0
  74. sllmwiki/templates/narratives/shared-skeleton.md +51 -0
  75. sllmwiki/templates/narratives/shared.md +39 -0
  76. sllmwiki/templates/narratives/survey/guide.md +41 -0
  77. sllmwiki/templates/narratives/survey/template.md +38 -0
  78. sllmwiki/templates/narratives/technical_brief_overview/guide.md +49 -0
  79. sllmwiki/templates/narratives/technical_brief_overview/template.md +42 -0
  80. sllmwiki/templates/release-book.yml +25 -0
  81. sllmwiki/templates/review-checklist.yml +17 -0
  82. sllmwiki/templates/wiki/.claude/settings.json +13 -0
  83. sllmwiki/templates/wiki/AGENTS.md +12 -0
  84. sllmwiki/templates/wiki/CLAUDE.md +1 -0
  85. sllmwiki/templates/wiki/Config.toml +27 -0
  86. sllmwiki/templates/wiki/README.md +26 -0
  87. sllmwiki/templates/wiki/gitignore +5 -0
  88. sllmwiki/templates/wiki/sllmwiki/.obsidian/graph.json +15 -0
  89. sllmwiki/templates/wiki-checks.yml +17 -0
  90. sllmwiki-1.2.0.dist-info/METADATA +141 -0
  91. sllmwiki-1.2.0.dist-info/RECORD +96 -0
  92. sllmwiki-1.2.0.dist-info/WHEEL +5 -0
  93. sllmwiki-1.2.0.dist-info/entry_points.txt +2 -0
  94. sllmwiki-1.2.0.dist-info/licenses/LICENSE +201 -0
  95. sllmwiki-1.2.0.dist-info/licenses/NOTICE +4 -0
  96. 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
@@ -0,0 +1,7 @@
1
+ """python3 -m sllmwiki <command> ...: the same as the sllmwiki command."""
2
+
3
+ import sys
4
+
5
+ from sllmwiki.cli import main
6
+
7
+ sys.exit(main())
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)