westminster-standards-cli 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.
- westminster_cli/__init__.py +5 -0
- westminster_cli/__main__.py +5 -0
- westminster_cli/cli.py +658 -0
- westminster_cli/corpus.py +126 -0
- westminster_cli/data/__init__.py +1 -0
- westminster_cli/data/standards.json +20467 -0
- westminster_cli/formatting.py +353 -0
- westminster_standards_cli-0.1.0.dist-info/METADATA +258 -0
- westminster_standards_cli-0.1.0.dist-info/RECORD +13 -0
- westminster_standards_cli-0.1.0.dist-info/WHEEL +5 -0
- westminster_standards_cli-0.1.0.dist-info/entry_points.txt +4 -0
- westminster_standards_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- westminster_standards_cli-0.1.0.dist-info/top_level.txt +1 -0
westminster_cli/cli.py
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import pydoc
|
|
5
|
+
import random
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .corpus import (
|
|
16
|
+
find_chapter_entries,
|
|
17
|
+
find_document,
|
|
18
|
+
find_entry,
|
|
19
|
+
load_documents,
|
|
20
|
+
search_entries,
|
|
21
|
+
)
|
|
22
|
+
from .formatting import (
|
|
23
|
+
format_chapter,
|
|
24
|
+
format_document_list,
|
|
25
|
+
format_entry,
|
|
26
|
+
format_entry_part,
|
|
27
|
+
format_entry_list,
|
|
28
|
+
format_home,
|
|
29
|
+
format_proofs,
|
|
30
|
+
format_quiz_summary,
|
|
31
|
+
format_search_results,
|
|
32
|
+
format_slash_commands,
|
|
33
|
+
format_sources,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DOCUMENT_IDS = {"wcf", "wlc", "wsc"}
|
|
38
|
+
# prompt_toolkit's FileHistory and readline write incompatible formats;
|
|
39
|
+
# sharing one file makes each rewrite the other's entries (with re-escaping),
|
|
40
|
+
# growing the file without bound. Keep them strictly separate.
|
|
41
|
+
HISTORY_FILE = Path.home() / ".westminster_standards_history"
|
|
42
|
+
READLINE_HISTORY_FILE = Path.home() / ".westminster_standards_history_readline"
|
|
43
|
+
|
|
44
|
+
# Producers whose output can be long; page these when writing to a TTY.
|
|
45
|
+
PAGE_THRESHOLD = 20
|
|
46
|
+
|
|
47
|
+
ANSI_ESCAPE_RE = re.compile(r"\033\[[0-9;]*m")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class CommandCompletion:
|
|
52
|
+
text: str
|
|
53
|
+
description: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
COMMAND_COMPLETIONS = (
|
|
57
|
+
CommandCompletion("/wcf ", "Show WCF chapter or section"),
|
|
58
|
+
CommandCompletion("/wsc ", "Show Shorter Catechism question"),
|
|
59
|
+
CommandCompletion("/wlc ", "Show Larger Catechism question"),
|
|
60
|
+
CommandCompletion("/q wsc ", "Print only a catechism question"),
|
|
61
|
+
CommandCompletion("/a wsc ", "Print only a catechism answer"),
|
|
62
|
+
CommandCompletion("/p wsc ", "Show an entry with scripture proof texts"),
|
|
63
|
+
CommandCompletion("/m wsc ", "Show an entry in modern English (2025 MESV)"),
|
|
64
|
+
CommandCompletion("/search ", "Search the standards"),
|
|
65
|
+
CommandCompletion("/list ", "List documents or entries"),
|
|
66
|
+
CommandCompletion("/quiz ", "Flashcard quiz (reveal answers, track score)"),
|
|
67
|
+
CommandCompletion("/stats", "Show corpus counts"),
|
|
68
|
+
CommandCompletion("/sources", "Show OPC source pages"),
|
|
69
|
+
CommandCompletion("/clear", "Clear the terminal"),
|
|
70
|
+
CommandCompletion("/help", "Show slash command menu"),
|
|
71
|
+
CommandCompletion("wcf ", "Show WCF chapter or section"),
|
|
72
|
+
CommandCompletion("wsc ", "Show Shorter Catechism question"),
|
|
73
|
+
CommandCompletion("wlc ", "Show Larger Catechism question"),
|
|
74
|
+
CommandCompletion("search ", "Search the standards"),
|
|
75
|
+
CommandCompletion("list ", "List documents or entries"),
|
|
76
|
+
CommandCompletion("quiz ", "Flashcard quiz (reveal answers, track score)"),
|
|
77
|
+
CommandCompletion("stats", "Show corpus counts"),
|
|
78
|
+
CommandCompletion("sources", "Show OPC source pages"),
|
|
79
|
+
CommandCompletion("clear", "Clear the terminal"),
|
|
80
|
+
CommandCompletion("help", "Show CLI help"),
|
|
81
|
+
CommandCompletion("exit", "Exit interactive mode"),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class WestminsterCompleter:
|
|
86
|
+
def __init__(self, documents=None):
|
|
87
|
+
self.documents = documents
|
|
88
|
+
|
|
89
|
+
def get_completions(self, document, complete_event):
|
|
90
|
+
prefix = document.text_before_cursor.lstrip()
|
|
91
|
+
if not prefix:
|
|
92
|
+
return
|
|
93
|
+
if " " not in prefix:
|
|
94
|
+
yield from self._command_completions(prefix)
|
|
95
|
+
return
|
|
96
|
+
if self.documents is not None:
|
|
97
|
+
yield from self._argument_completions(prefix)
|
|
98
|
+
|
|
99
|
+
def _command_completions(self, prefix):
|
|
100
|
+
from prompt_toolkit.completion import Completion
|
|
101
|
+
|
|
102
|
+
start_position = -len(prefix)
|
|
103
|
+
for command in COMMAND_COMPLETIONS:
|
|
104
|
+
if command.text.startswith(prefix):
|
|
105
|
+
yield Completion(
|
|
106
|
+
command.text,
|
|
107
|
+
start_position=start_position,
|
|
108
|
+
display=command.text.rstrip(),
|
|
109
|
+
display_meta=command.description,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _argument_completions(self, prefix):
|
|
113
|
+
from prompt_toolkit.completion import Completion
|
|
114
|
+
|
|
115
|
+
tokens = prefix.split()
|
|
116
|
+
ends_with_space = prefix.endswith(" ")
|
|
117
|
+
current = "" if ends_with_space else tokens[-1]
|
|
118
|
+
word_index = len(tokens) if ends_with_space else len(tokens) - 1
|
|
119
|
+
start_position = -len(current)
|
|
120
|
+
|
|
121
|
+
def emit(value, meta):
|
|
122
|
+
if value.startswith(current):
|
|
123
|
+
yield Completion(
|
|
124
|
+
value, start_position=start_position, display=value, display_meta=meta
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
first = tokens[0].lstrip("/").casefold()
|
|
128
|
+
|
|
129
|
+
if first in {"q", "a", "p", "m"}:
|
|
130
|
+
if word_index == 1:
|
|
131
|
+
doc_ids = ("wsc", "wlc") if first in {"q", "a"} else ("wcf", "wsc", "wlc")
|
|
132
|
+
for doc_id in doc_ids:
|
|
133
|
+
yield from emit(doc_id, "Document")
|
|
134
|
+
return
|
|
135
|
+
if len(tokens) < 2:
|
|
136
|
+
return
|
|
137
|
+
first = tokens[1].casefold()
|
|
138
|
+
word_index -= 1
|
|
139
|
+
|
|
140
|
+
if first in DOCUMENT_IDS:
|
|
141
|
+
document = find_document(self.documents, first)
|
|
142
|
+
if document is None:
|
|
143
|
+
return
|
|
144
|
+
if word_index == 1:
|
|
145
|
+
for entry in document.entries:
|
|
146
|
+
yield from emit(entry.ref, _entry_meta(entry))
|
|
147
|
+
elif word_index == 2:
|
|
148
|
+
if any(entry.kind == "qa" for entry in document.entries):
|
|
149
|
+
yield from emit("--question", "Only the question")
|
|
150
|
+
yield from emit("--answer", "Only the answer")
|
|
151
|
+
yield from emit("--proofs", "Show scripture proof texts")
|
|
152
|
+
yield from emit("--mesv", "Modern English Study Version")
|
|
153
|
+
yield from emit("--compare", "Constitutional and MESV together")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if first == "list" and word_index == 1:
|
|
157
|
+
for doc_id in ("wcf", "wsc", "wlc"):
|
|
158
|
+
yield from emit(doc_id, "Document")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
if first == "quiz":
|
|
162
|
+
if word_index == 1:
|
|
163
|
+
for doc in self.documents:
|
|
164
|
+
if any(entry.kind == "qa" for entry in doc.entries):
|
|
165
|
+
yield from emit(doc.id, doc.short_title)
|
|
166
|
+
elif word_index == 2:
|
|
167
|
+
for count in ("5", "10", "20"):
|
|
168
|
+
yield from emit(count, "Number of questions")
|
|
169
|
+
|
|
170
|
+
async def get_completions_async(self, document, complete_event):
|
|
171
|
+
for completion in self.get_completions(document, complete_event):
|
|
172
|
+
yield completion
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _entry_meta(entry) -> str:
|
|
176
|
+
text = entry.question or entry.heading or entry.text or ""
|
|
177
|
+
return f"{text[:40]}…" if len(text) > 40 else text
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
181
|
+
parser = argparse.ArgumentParser(
|
|
182
|
+
prog="ws",
|
|
183
|
+
usage=(
|
|
184
|
+
"%(prog)s [-h] {wcf,wlc,wsc} ref | "
|
|
185
|
+
"{list,search,quiz,stats,sources,clear} ..."
|
|
186
|
+
),
|
|
187
|
+
description="Read, search, and quiz yourself on the Westminster Standards.",
|
|
188
|
+
epilog=(
|
|
189
|
+
"Examples: ws wcf 1, ws wcf 1.1, ws wsc 1 --question, "
|
|
190
|
+
'ws search "chief end". The explicit form `ws show DOC REF` still works.'
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
194
|
+
|
|
195
|
+
list_parser = subparsers.add_parser("list", help="List documents or entries in a document.")
|
|
196
|
+
list_parser.add_argument("doc", nargs="?", help="Document id, such as wsc, wlc, or wcf.")
|
|
197
|
+
|
|
198
|
+
search_parser = subparsers.add_parser("search", help="Search across the bundled corpus.")
|
|
199
|
+
search_parser.add_argument("query", nargs="+", help="Search terms.")
|
|
200
|
+
|
|
201
|
+
quiz_parser = subparsers.add_parser(
|
|
202
|
+
"quiz", help="Flashcard quiz: reveal answers and track your score."
|
|
203
|
+
)
|
|
204
|
+
quiz_parser.add_argument(
|
|
205
|
+
"doc", nargs="?", default="wsc", help="Document id to quiz from. Defaults to wsc."
|
|
206
|
+
)
|
|
207
|
+
quiz_parser.add_argument(
|
|
208
|
+
"count", nargs="?", type=int, default=10, help="Number of questions. Defaults to 10."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
subparsers.add_parser("stats", help="Show corpus counts.")
|
|
212
|
+
subparsers.add_parser("sources", help="Show OPC source pages for the bundled corpus.")
|
|
213
|
+
subparsers.add_parser("clear", help="Clear the terminal.")
|
|
214
|
+
|
|
215
|
+
return parser
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def build_show_parser() -> argparse.ArgumentParser:
|
|
219
|
+
parser = argparse.ArgumentParser(
|
|
220
|
+
prog="ws show",
|
|
221
|
+
description="Show a catechism question, confession section, or WCF chapter.",
|
|
222
|
+
)
|
|
223
|
+
parser.add_argument("doc", help="Document id, such as wsc, wlc, or wcf.")
|
|
224
|
+
parser.add_argument("ref", help="Entry reference, such as 1 or 1.1.")
|
|
225
|
+
part_group = parser.add_mutually_exclusive_group()
|
|
226
|
+
part_group.add_argument(
|
|
227
|
+
"-q",
|
|
228
|
+
"--question",
|
|
229
|
+
action="store_const",
|
|
230
|
+
const="question",
|
|
231
|
+
dest="part",
|
|
232
|
+
help="Only print the question for a catechism entry.",
|
|
233
|
+
)
|
|
234
|
+
part_group.add_argument(
|
|
235
|
+
"-a",
|
|
236
|
+
"--answer",
|
|
237
|
+
action="store_const",
|
|
238
|
+
const="answer",
|
|
239
|
+
dest="part",
|
|
240
|
+
help="Only print the answer for a catechism entry.",
|
|
241
|
+
)
|
|
242
|
+
parser.add_argument(
|
|
243
|
+
"-p",
|
|
244
|
+
"--proofs",
|
|
245
|
+
action="store_true",
|
|
246
|
+
help="Show the OPC scripture proof texts beneath the text.",
|
|
247
|
+
)
|
|
248
|
+
version_group = parser.add_mutually_exclusive_group()
|
|
249
|
+
version_group.add_argument(
|
|
250
|
+
"-m",
|
|
251
|
+
"--mesv",
|
|
252
|
+
action="store_true",
|
|
253
|
+
help="Show the 2025 Modern English Study Version text.",
|
|
254
|
+
)
|
|
255
|
+
version_group.add_argument(
|
|
256
|
+
"--compare",
|
|
257
|
+
action="store_true",
|
|
258
|
+
help="Show the constitutional and 2025 MESV texts together.",
|
|
259
|
+
)
|
|
260
|
+
return parser
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
264
|
+
raw_args = sys.argv[1:] if argv is None else argv
|
|
265
|
+
documents = load_documents()
|
|
266
|
+
|
|
267
|
+
if not raw_args:
|
|
268
|
+
return run_repl(documents)
|
|
269
|
+
return dispatch(documents, raw_args)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def dispatch(documents, raw_args: list[str], read_line=input) -> int:
|
|
273
|
+
slash_args = _normalize_slash_args(raw_args)
|
|
274
|
+
if slash_args == []:
|
|
275
|
+
print(format_slash_commands(color=sys.stdout.isatty()))
|
|
276
|
+
return 0
|
|
277
|
+
raw_args = slash_args
|
|
278
|
+
|
|
279
|
+
if raw_args and raw_args[0].casefold() in DOCUMENT_IDS:
|
|
280
|
+
return _show(documents, raw_args)
|
|
281
|
+
if raw_args and raw_args[0] == "show":
|
|
282
|
+
return _show(documents, raw_args[1:])
|
|
283
|
+
if raw_args and raw_args[0] == "clear":
|
|
284
|
+
_clear_screen()
|
|
285
|
+
return 0
|
|
286
|
+
if raw_args and raw_args[0] == "help":
|
|
287
|
+
build_parser().print_help()
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
parser = build_parser()
|
|
291
|
+
args = parser.parse_args(raw_args)
|
|
292
|
+
|
|
293
|
+
if args.command == "list":
|
|
294
|
+
if args.doc is None:
|
|
295
|
+
print(format_document_list(documents))
|
|
296
|
+
return 0
|
|
297
|
+
document = find_document(documents, args.doc)
|
|
298
|
+
if document is None:
|
|
299
|
+
return _error(f"Unknown document: {args.doc}")
|
|
300
|
+
_emit(format_entry_list(document, color=_color_enabled()), page=True)
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
if args.command == "search":
|
|
304
|
+
query = " ".join(args.query)
|
|
305
|
+
_emit(format_search_results(search_entries(documents, query), color=_color_enabled()), page=True)
|
|
306
|
+
return 0
|
|
307
|
+
|
|
308
|
+
if args.command == "quiz":
|
|
309
|
+
if args.count < 1:
|
|
310
|
+
return _error("count must be at least 1")
|
|
311
|
+
return run_quiz(documents, args.doc, args.count, read_line)
|
|
312
|
+
|
|
313
|
+
if args.command == "stats":
|
|
314
|
+
total = sum(len(document.entries) for document in documents)
|
|
315
|
+
qa_count = sum(
|
|
316
|
+
1 for document in documents for entry in document.entries if entry.kind == "qa"
|
|
317
|
+
)
|
|
318
|
+
print(f"Documents: {len(documents)}")
|
|
319
|
+
print(f"Entries: {total}")
|
|
320
|
+
print(f"Catechism questions: {qa_count}")
|
|
321
|
+
return 0
|
|
322
|
+
|
|
323
|
+
if args.command == "sources":
|
|
324
|
+
print(format_sources(documents))
|
|
325
|
+
return 0
|
|
326
|
+
|
|
327
|
+
if args.command == "clear":
|
|
328
|
+
_clear_screen()
|
|
329
|
+
return 0
|
|
330
|
+
|
|
331
|
+
parser.print_help()
|
|
332
|
+
return 2
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def run_quiz(documents, doc_id: str, count: int, read_line=input, *, out=print) -> int:
|
|
336
|
+
document = find_document(documents, doc_id)
|
|
337
|
+
if document is None:
|
|
338
|
+
return _error(f"Unknown document: {doc_id}")
|
|
339
|
+
questions = [entry for entry in document.entries if entry.kind == "qa"]
|
|
340
|
+
if not questions:
|
|
341
|
+
return _error(f"{document.id} does not contain quiz questions")
|
|
342
|
+
|
|
343
|
+
sample_size = min(count, len(questions))
|
|
344
|
+
selection = random.sample(questions, sample_size)
|
|
345
|
+
out(f"Quizzing {document.title} - {sample_size} questions. Enter to reveal, s skip, q quit.")
|
|
346
|
+
|
|
347
|
+
correct = 0
|
|
348
|
+
answered = 0
|
|
349
|
+
for index, entry in enumerate(selection, start=1):
|
|
350
|
+
out("")
|
|
351
|
+
out(f"{index}. {entry.label}")
|
|
352
|
+
out(f"Q. {entry.question}")
|
|
353
|
+
try:
|
|
354
|
+
reveal = read_line("[Enter to reveal] ").strip().casefold()
|
|
355
|
+
except (EOFError, KeyboardInterrupt):
|
|
356
|
+
out("")
|
|
357
|
+
break
|
|
358
|
+
if reveal in {"q", "quit", "exit"}:
|
|
359
|
+
break
|
|
360
|
+
if reveal in {"s", "skip"}:
|
|
361
|
+
continue
|
|
362
|
+
out(f"A. {entry.answer}")
|
|
363
|
+
try:
|
|
364
|
+
verdict = read_line("Got it? [y/n] ").strip().casefold()
|
|
365
|
+
except (EOFError, KeyboardInterrupt):
|
|
366
|
+
out("")
|
|
367
|
+
break
|
|
368
|
+
answered += 1
|
|
369
|
+
if verdict in {"y", "yes"}:
|
|
370
|
+
correct += 1
|
|
371
|
+
|
|
372
|
+
out("")
|
|
373
|
+
out(format_quiz_summary(correct, answered, color=_color_enabled()))
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def run_repl(documents=None) -> int:
|
|
378
|
+
if documents is None:
|
|
379
|
+
documents = load_documents()
|
|
380
|
+
print(format_home(documents, color=sys.stdout.isatty()))
|
|
381
|
+
print()
|
|
382
|
+
print("Interactive mode. Type / for commands, help for help, or exit to quit.")
|
|
383
|
+
|
|
384
|
+
prompt_session = _build_prompt_session(documents)
|
|
385
|
+
if prompt_session is not None:
|
|
386
|
+
return _run_prompt_toolkit_repl(documents, prompt_session)
|
|
387
|
+
|
|
388
|
+
return _run_input_repl(documents)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _run_prompt_toolkit_repl(documents, prompt_session) -> int:
|
|
392
|
+
read_line = _plain_prompt(prompt_session)
|
|
393
|
+
while True:
|
|
394
|
+
try:
|
|
395
|
+
line = prompt_session.prompt([("class:prompt", "ws> ")])
|
|
396
|
+
except (EOFError, KeyboardInterrupt):
|
|
397
|
+
print()
|
|
398
|
+
return 0
|
|
399
|
+
if _dispatch_repl_line(documents, line, read_line):
|
|
400
|
+
return 0
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _run_input_repl(documents) -> int:
|
|
404
|
+
_enable_history()
|
|
405
|
+
|
|
406
|
+
while True:
|
|
407
|
+
try:
|
|
408
|
+
line = input("ws> ")
|
|
409
|
+
except EOFError:
|
|
410
|
+
print()
|
|
411
|
+
return 0
|
|
412
|
+
if _dispatch_repl_line(documents, line, input):
|
|
413
|
+
return 0
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _plain_prompt(prompt_session):
|
|
417
|
+
"""A read_line callable that uses the session's prompt without completion menus."""
|
|
418
|
+
|
|
419
|
+
def read_line(message: str) -> str:
|
|
420
|
+
return prompt_session.prompt(message, completer=None, auto_suggest=None)
|
|
421
|
+
|
|
422
|
+
return read_line
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _dispatch_repl_line(documents, line: str, read_line=input) -> bool:
|
|
426
|
+
command = line.strip()
|
|
427
|
+
if not command:
|
|
428
|
+
return False
|
|
429
|
+
if command.casefold() in {"exit", "quit", "q"}:
|
|
430
|
+
return True
|
|
431
|
+
try:
|
|
432
|
+
args = shlex.split(command)
|
|
433
|
+
except ValueError as exc:
|
|
434
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
435
|
+
return False
|
|
436
|
+
try:
|
|
437
|
+
dispatch(documents, args, read_line)
|
|
438
|
+
except SystemExit as exc:
|
|
439
|
+
if exc.code in (0, None):
|
|
440
|
+
return True
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _build_prompt_session(documents=None):
|
|
445
|
+
try:
|
|
446
|
+
from prompt_toolkit import PromptSession
|
|
447
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
448
|
+
from prompt_toolkit.filters import has_completions
|
|
449
|
+
from prompt_toolkit.history import FileHistory, InMemoryHistory, ThreadedHistory
|
|
450
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
451
|
+
from prompt_toolkit.styles import Style
|
|
452
|
+
except ImportError:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
key_bindings = KeyBindings()
|
|
456
|
+
|
|
457
|
+
@key_bindings.add("c-m", filter=has_completions, eager=True)
|
|
458
|
+
def _(event):
|
|
459
|
+
_accept_completion_or_submit(event.current_buffer)
|
|
460
|
+
|
|
461
|
+
@key_bindings.add("c-j", filter=has_completions, eager=True)
|
|
462
|
+
def _(event):
|
|
463
|
+
_accept_completion_or_submit(event.current_buffer)
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
# ThreadedHistory loads the file in the background so a large
|
|
467
|
+
# history can never block the input loop.
|
|
468
|
+
history = ThreadedHistory(FileHistory(str(HISTORY_FILE)))
|
|
469
|
+
except OSError:
|
|
470
|
+
history = InMemoryHistory()
|
|
471
|
+
|
|
472
|
+
style = Style.from_dict(
|
|
473
|
+
{
|
|
474
|
+
"prompt": "ansicyan bold",
|
|
475
|
+
"bottom-toolbar": "bg:#333333 #cccccc",
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
return PromptSession(
|
|
480
|
+
completer=WestminsterCompleter(documents),
|
|
481
|
+
complete_while_typing=True,
|
|
482
|
+
history=history,
|
|
483
|
+
auto_suggest=AutoSuggestFromHistory(),
|
|
484
|
+
bottom_toolbar=_bottom_toolbar(documents),
|
|
485
|
+
style=style,
|
|
486
|
+
key_bindings=key_bindings,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _bottom_toolbar(documents):
|
|
491
|
+
if not documents:
|
|
492
|
+
return None
|
|
493
|
+
doc_count = len(documents)
|
|
494
|
+
qa_count = sum(
|
|
495
|
+
1 for document in documents for entry in document.entries if entry.kind == "qa"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
def render():
|
|
499
|
+
return (
|
|
500
|
+
"Tab complete · / commands · quiz for flashcards · exit to quit"
|
|
501
|
+
f" | Docs {doc_count} · Q/A {qa_count}"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
return render
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _accept_completion_or_submit(buffer) -> None:
|
|
508
|
+
completion = buffer.complete_state.current_completion
|
|
509
|
+
if completion is None:
|
|
510
|
+
buffer.validate_and_handle()
|
|
511
|
+
return
|
|
512
|
+
buffer.apply_completion(completion)
|
|
513
|
+
if completion.text.endswith(" "):
|
|
514
|
+
buffer.start_completion(select_first=False)
|
|
515
|
+
else:
|
|
516
|
+
buffer.validate_and_handle()
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _enable_history() -> None:
|
|
520
|
+
if not sys.stdin.isatty():
|
|
521
|
+
return
|
|
522
|
+
try:
|
|
523
|
+
import atexit
|
|
524
|
+
import readline
|
|
525
|
+
except ImportError:
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
try:
|
|
529
|
+
readline.read_history_file(str(READLINE_HISTORY_FILE))
|
|
530
|
+
except (OSError, ValueError):
|
|
531
|
+
pass
|
|
532
|
+
atexit.register(_save_history, readline)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _save_history(readline) -> None:
|
|
536
|
+
try:
|
|
537
|
+
readline.write_history_file(str(READLINE_HISTORY_FILE))
|
|
538
|
+
except OSError:
|
|
539
|
+
pass
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _has_mesv(entry) -> bool:
|
|
543
|
+
if entry.kind == "qa":
|
|
544
|
+
return bool(entry.question_mesv and entry.answer_mesv)
|
|
545
|
+
return bool(entry.text_mesv)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _error(message: str) -> int:
|
|
549
|
+
print(f"error: {message}", file=sys.stderr)
|
|
550
|
+
return 1
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _clear_screen() -> None:
|
|
554
|
+
print("\033[2J\033[H", end="")
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _color_enabled() -> bool:
|
|
558
|
+
return sys.stdout.isatty()
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _emit(text: str, page: bool = False) -> None:
|
|
562
|
+
"""Print text, routing long output through a pager when writing to a TTY."""
|
|
563
|
+
if page and sys.stdout.isatty() and text.count("\n") + 1 > PAGE_THRESHOLD:
|
|
564
|
+
_page(text)
|
|
565
|
+
return
|
|
566
|
+
print(text)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _page(text: str) -> None:
|
|
570
|
+
less = shutil.which("less")
|
|
571
|
+
if less:
|
|
572
|
+
# -R renders ANSI colors instead of showing raw escape codes;
|
|
573
|
+
# -F quits immediately if it fits one screen; -X keeps the
|
|
574
|
+
# output on screen after quitting.
|
|
575
|
+
try:
|
|
576
|
+
subprocess.run([less, "-RFX"], input=text.encode(), check=False)
|
|
577
|
+
return
|
|
578
|
+
except OSError:
|
|
579
|
+
pass
|
|
580
|
+
# pydoc's fallback pagers don't render ANSI codes, so strip them.
|
|
581
|
+
pydoc.pager(ANSI_ESCAPE_RE.sub("", text))
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _show(documents, argv: list[str]) -> int:
|
|
585
|
+
show_parser = build_show_parser()
|
|
586
|
+
args = show_parser.parse_args(argv)
|
|
587
|
+
document = find_document(documents, args.doc)
|
|
588
|
+
if document is None:
|
|
589
|
+
return _error(f"Unknown document: {args.doc}")
|
|
590
|
+
entry = find_entry(document, args.ref)
|
|
591
|
+
if entry is not None:
|
|
592
|
+
if (args.mesv or args.compare) and not _has_mesv(entry):
|
|
593
|
+
return _error(f"No MESV text available for {document.id} {args.ref}")
|
|
594
|
+
if args.part:
|
|
595
|
+
if entry.kind != "qa":
|
|
596
|
+
return _error("--question and --answer are only valid for catechism entries")
|
|
597
|
+
print(format_entry_part(entry, args.part, mesv=args.mesv))
|
|
598
|
+
if args.proofs:
|
|
599
|
+
proofs = format_proofs(entry, color=_color_enabled())
|
|
600
|
+
if proofs:
|
|
601
|
+
print()
|
|
602
|
+
print(proofs)
|
|
603
|
+
return 0
|
|
604
|
+
print(
|
|
605
|
+
format_entry(
|
|
606
|
+
entry,
|
|
607
|
+
color=_color_enabled(),
|
|
608
|
+
proofs=args.proofs,
|
|
609
|
+
mesv=args.mesv,
|
|
610
|
+
compare=args.compare,
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
return 0
|
|
614
|
+
if args.part:
|
|
615
|
+
return _error("--question and --answer are only valid for catechism entries")
|
|
616
|
+
if document.id == "wcf" and args.ref.isdigit():
|
|
617
|
+
entries = find_chapter_entries(document, args.ref)
|
|
618
|
+
if entries:
|
|
619
|
+
_emit(
|
|
620
|
+
format_chapter(
|
|
621
|
+
document,
|
|
622
|
+
args.ref,
|
|
623
|
+
entries,
|
|
624
|
+
color=_color_enabled(),
|
|
625
|
+
proofs=args.proofs,
|
|
626
|
+
mesv=args.mesv,
|
|
627
|
+
compare=args.compare,
|
|
628
|
+
),
|
|
629
|
+
page=True,
|
|
630
|
+
)
|
|
631
|
+
return 0
|
|
632
|
+
return _error(f"Unknown reference for {document.id}: {args.ref}")
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _normalize_slash_args(argv: list[str]) -> list[str]:
|
|
636
|
+
if not argv:
|
|
637
|
+
return argv
|
|
638
|
+
first = argv[0]
|
|
639
|
+
if not first.startswith("/"):
|
|
640
|
+
return argv
|
|
641
|
+
|
|
642
|
+
command = first[1:].casefold()
|
|
643
|
+
rest = argv[1:]
|
|
644
|
+
if command in {"", "help"}:
|
|
645
|
+
return []
|
|
646
|
+
if command in DOCUMENT_IDS:
|
|
647
|
+
return [command, *rest]
|
|
648
|
+
if command == "q":
|
|
649
|
+
return [*rest, "--question"]
|
|
650
|
+
if command == "a":
|
|
651
|
+
return [*rest, "--answer"]
|
|
652
|
+
if command == "p":
|
|
653
|
+
return [*rest, "--proofs"]
|
|
654
|
+
if command == "m":
|
|
655
|
+
return [*rest, "--mesv"]
|
|
656
|
+
if command in {"list", "search", "quiz", "stats", "sources", "clear"}:
|
|
657
|
+
return [command, *rest]
|
|
658
|
+
return argv
|