bltzr 0.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.
- blitzer/__init__.py +22 -0
- blitzer/cli.py +669 -0
- blitzer/config.py +371 -0
- blitzer/core.py +1373 -0
- blitzer/downloads.py +369 -0
- blitzer/language-registry.json +350 -0
- blitzer/merging.py +164 -0
- blitzer/processing.py +476 -0
- bltzr-0.2.0.dist-info/METADATA +418 -0
- bltzr-0.2.0.dist-info/RECORD +14 -0
- bltzr-0.2.0.dist-info/WHEEL +5 -0
- bltzr-0.2.0.dist-info/entry_points.txt +2 -0
- bltzr-0.2.0.dist-info/licenses/LICENSE +340 -0
- bltzr-0.2.0.dist-info/top_level.txt +1 -0
blitzer/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Identify the Blitzer package without starting the application.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Keep importing the package simple and free of startup work.
|
|
6
|
+
|
|
7
|
+
In scope
|
|
8
|
+
--------
|
|
9
|
+
- The package's identity and a short explanation of where to begin.
|
|
10
|
+
|
|
11
|
+
Out of scope
|
|
12
|
+
------------
|
|
13
|
+
- Running commands, loading settings or opening files and databases.
|
|
14
|
+
- Defining processing rules or initializing shared application state.
|
|
15
|
+
|
|
16
|
+
Start here
|
|
17
|
+
----------
|
|
18
|
+
Read cli.py for commands and core.py for the callable application API.
|
|
19
|
+
Read processing.py for the pure text algorithms and config.py for
|
|
20
|
+
settings.
|
|
21
|
+
Read downloads.py for registered GitHub releases and archive handling.
|
|
22
|
+
"""
|
blitzer/cli.py
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"""Handle commands, user input and presentation of results.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Turn terminal arguments into calls to the application API, then present
|
|
6
|
+
results and errors in a format the user selected.
|
|
7
|
+
|
|
8
|
+
In scope
|
|
9
|
+
--------
|
|
10
|
+
- Click commands, flags, help and command-line argument validation.
|
|
11
|
+
- Reading explicit text, UTF-8 files or piped standard input.
|
|
12
|
+
- Interactive confirmations and the decision to save or preview data.
|
|
13
|
+
- Local/registered installation, explicit updates and pack packaging.
|
|
14
|
+
- Grouping reusable pack development commands under blitzer dev.
|
|
15
|
+
- Text, TSV, JSON and report output, including escaped word
|
|
16
|
+
highlighting.
|
|
17
|
+
- Sending vocabulary to stdout and diagnostics to stderr.
|
|
18
|
+
|
|
19
|
+
Out of scope
|
|
20
|
+
------------
|
|
21
|
+
- Tokenization, normalization, filtering, counting and sorting
|
|
22
|
+
algorithms.
|
|
23
|
+
- SQL queries, pack construction, locks and persistent data changes.
|
|
24
|
+
- Finding config files, resolving configured paths and parsing TOML.
|
|
25
|
+
|
|
26
|
+
Start here
|
|
27
|
+
----------
|
|
28
|
+
Read blitz for the main command's sequence. Formatting helpers are above
|
|
29
|
+
it; pack and known-list commands follow it. core.py provides the API,
|
|
30
|
+
processing.py implements text rules, and config.py owns configuration.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import csv
|
|
34
|
+
import html
|
|
35
|
+
import io
|
|
36
|
+
import json
|
|
37
|
+
import re
|
|
38
|
+
import sqlite3
|
|
39
|
+
import sys
|
|
40
|
+
from contextlib import contextmanager
|
|
41
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
import click
|
|
45
|
+
|
|
46
|
+
from blitzer.config import FORMATS, MARKUPS, SORTS
|
|
47
|
+
from blitzer.core import BlitzerService, Context
|
|
48
|
+
from blitzer.downloads import REGISTRY
|
|
49
|
+
|
|
50
|
+
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
VERSION = version("bltzr")
|
|
54
|
+
except PackageNotFoundError:
|
|
55
|
+
VERSION = "0.1.0"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def _errors():
|
|
60
|
+
"""Translate expected errors into readable CLI failures."""
|
|
61
|
+
try:
|
|
62
|
+
yield
|
|
63
|
+
except (OSError, ValueError, sqlite3.Error) as error:
|
|
64
|
+
raise click.ClickException(str(error)) from error
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _service(config, no_config, plugins_dir):
|
|
68
|
+
"""Create a service from CLI configuration selections."""
|
|
69
|
+
return BlitzerService(
|
|
70
|
+
config, use_config=not no_config, plugins_dir=plugins_dir
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _escape_markup(text: str, style: str) -> str:
|
|
75
|
+
"""Escape original text for the requested markup."""
|
|
76
|
+
if style == "html":
|
|
77
|
+
return html.escape(text)
|
|
78
|
+
if style == "markdown":
|
|
79
|
+
return re.sub(r"([\\`*_{}\[\]<>#|])", r"\\\1", text)
|
|
80
|
+
if style == "org":
|
|
81
|
+
return text.replace("\\", "\\\\").replace("*", r"\*")
|
|
82
|
+
return text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _highlight(context: Context, style: str) -> str:
|
|
86
|
+
"""Emphasize the flagged occurrence in escaped context.
|
|
87
|
+
|
|
88
|
+
Off returns the original text. Rendering changes neither the context
|
|
89
|
+
nor its offsets and does not read any external state.
|
|
90
|
+
"""
|
|
91
|
+
if style == "off":
|
|
92
|
+
return context.text
|
|
93
|
+
start, end = context.highlight_start, context.highlight_end
|
|
94
|
+
left = _escape_markup(context.text[:start], style)
|
|
95
|
+
word = _escape_markup(context.text[start:end], style)
|
|
96
|
+
right = _escape_markup(context.text[end:], style)
|
|
97
|
+
opening, closing = {
|
|
98
|
+
"html": ("<b>", "</b>"),
|
|
99
|
+
"markdown": ("**", "**"),
|
|
100
|
+
"org": ("*", "*"),
|
|
101
|
+
}[style]
|
|
102
|
+
return left + opening + word + closing + right
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _json_entry(entry, include_context) -> dict:
|
|
106
|
+
"""Return a JSON record with optional plain contexts."""
|
|
107
|
+
record = {"term": entry.term, "count": entry.count}
|
|
108
|
+
if entry.global_frequency is not None:
|
|
109
|
+
record["global_frequency"] = entry.global_frequency
|
|
110
|
+
if include_context:
|
|
111
|
+
record["contexts"] = [
|
|
112
|
+
{
|
|
113
|
+
"text": item.text,
|
|
114
|
+
"highlight_start": item.highlight_start,
|
|
115
|
+
"highlight_end": item.highlight_end,
|
|
116
|
+
}
|
|
117
|
+
for item in entry.contexts
|
|
118
|
+
]
|
|
119
|
+
return record
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _report_preamble(source, prompt) -> str:
|
|
123
|
+
"""Return report sections in their display order."""
|
|
124
|
+
sections = []
|
|
125
|
+
if prompt is not None:
|
|
126
|
+
sections.append("PROMPT\n" + prompt + "\n\n")
|
|
127
|
+
if source is not None:
|
|
128
|
+
sections.append("SOURCE\n" + source + "\n\n")
|
|
129
|
+
return "".join(sections) + "VOCABULARY\n"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _vocabulary_row(entry, freq, context, bold) -> list:
|
|
133
|
+
"""Build a TSV row without changing vocabulary entries."""
|
|
134
|
+
row = [entry.term]
|
|
135
|
+
if freq:
|
|
136
|
+
row.append(entry.count)
|
|
137
|
+
if context:
|
|
138
|
+
joiner = "<br>" if bold == "html" else " | "
|
|
139
|
+
row.append(
|
|
140
|
+
joiner.join(
|
|
141
|
+
" ".join(_highlight(item, bold).split())
|
|
142
|
+
for item in entry.contexts
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return row
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _render(entries, *, output_format, freq, context, bold, source, prompt):
|
|
149
|
+
"""Return formatted output without external writes.
|
|
150
|
+
|
|
151
|
+
JSON always includes counts and keeps contexts unformatted. Text and
|
|
152
|
+
TSV quote cells through csv.writer; report adds explicit text
|
|
153
|
+
sections.
|
|
154
|
+
"""
|
|
155
|
+
if output_format == "json":
|
|
156
|
+
records = [_json_entry(entry, context) for entry in entries]
|
|
157
|
+
return json.dumps(records, ensure_ascii=False, indent=2) + "\n"
|
|
158
|
+
stream = io.StringIO(newline="")
|
|
159
|
+
if output_format == "report":
|
|
160
|
+
stream.write(_report_preamble(source, prompt))
|
|
161
|
+
writer = csv.writer(stream, delimiter="\t", lineterminator="\n")
|
|
162
|
+
if output_format == "tsv":
|
|
163
|
+
writer.writerow(
|
|
164
|
+
[
|
|
165
|
+
"term",
|
|
166
|
+
*(["count"] if freq else []),
|
|
167
|
+
*(["context"] if context else []),
|
|
168
|
+
]
|
|
169
|
+
)
|
|
170
|
+
writer.writerows(
|
|
171
|
+
_vocabulary_row(entry, freq, context, bold) for entry in entries
|
|
172
|
+
)
|
|
173
|
+
return stream.getvalue()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _read_input(text, input_file) -> str:
|
|
177
|
+
"""Read explicit text, a UTF-8 file or piped stdin in that order."""
|
|
178
|
+
if text is not None:
|
|
179
|
+
return text
|
|
180
|
+
if input_file is not None:
|
|
181
|
+
return input_file.read_text(encoding="utf-8")
|
|
182
|
+
if sys.stdin.isatty():
|
|
183
|
+
raise click.UsageError(
|
|
184
|
+
"Provide --text, --file, or pipe UTF-8 text to stdin"
|
|
185
|
+
)
|
|
186
|
+
return click.get_text_stream(
|
|
187
|
+
"stdin", encoding="utf-8", errors="strict"
|
|
188
|
+
).read()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _presentation_options(settings, **overrides) -> dict:
|
|
192
|
+
"""Resolve and validate display flags.
|
|
193
|
+
|
|
194
|
+
Explicit non-None values override the supplied language settings.
|
|
195
|
+
Prompt text is required only when a report requests a prompt
|
|
196
|
+
section.
|
|
197
|
+
"""
|
|
198
|
+
options = {
|
|
199
|
+
name: settings[name] if value is None else value
|
|
200
|
+
for name, value in overrides.items()
|
|
201
|
+
}
|
|
202
|
+
if (options["prompt"] or options["src"]) and options["format"] != "report":
|
|
203
|
+
raise click.UsageError("--prompt and --src require --format report")
|
|
204
|
+
options["prompt_text"] = (
|
|
205
|
+
settings.get("prompt_text") if options["prompt"] else None
|
|
206
|
+
)
|
|
207
|
+
return options
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _should_save_contexts(policy, explicit, test_known) -> bool:
|
|
211
|
+
"""Resolve saving policy and any required prompt.
|
|
212
|
+
|
|
213
|
+
Test mode suppresses all history writes. A pipeline must explicitly
|
|
214
|
+
accept or decline a prompt policy so stdin remains vocabulary input.
|
|
215
|
+
"""
|
|
216
|
+
if test_known:
|
|
217
|
+
return False
|
|
218
|
+
if explicit is not None:
|
|
219
|
+
return explicit
|
|
220
|
+
if policy != "prompt":
|
|
221
|
+
return policy == "always"
|
|
222
|
+
if not sys.stdin.isatty():
|
|
223
|
+
raise click.UsageError(
|
|
224
|
+
"Context saving asks for a prompt: specify --save-context "
|
|
225
|
+
"or --no-save-context in a pipeline"
|
|
226
|
+
)
|
|
227
|
+
return click.confirm(
|
|
228
|
+
"Save surviving vocabulary contexts?", default=False, err=True
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _handle_known_update(service, language, entries, path, update, test_known):
|
|
233
|
+
"""Update or preview known terms and report to stderr."""
|
|
234
|
+
if not update and not test_known:
|
|
235
|
+
return
|
|
236
|
+
change = service.update_known(
|
|
237
|
+
language, entries, path=path, dry_run=test_known
|
|
238
|
+
)
|
|
239
|
+
action = "Would add" if test_known else "Added"
|
|
240
|
+
click.echo(
|
|
241
|
+
f"{action} {len(change.additions)} matched terms to {change.path}: "
|
|
242
|
+
f"{', '.join(change.additions)}",
|
|
243
|
+
err=True,
|
|
244
|
+
)
|
|
245
|
+
if update and not test_known:
|
|
246
|
+
click.echo(
|
|
247
|
+
"Warning: matched words are not evidence of knowledge; "
|
|
248
|
+
"automatic known updating is discouraged.",
|
|
249
|
+
err=True,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _handle_context_saving(service, language, entries, saving):
|
|
254
|
+
"""Save requested history and report to stderr."""
|
|
255
|
+
if not saving:
|
|
256
|
+
return
|
|
257
|
+
count = service.save_contexts(language, entries)
|
|
258
|
+
click.echo(f"Saved {count} new contexts.", err=True)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
262
|
+
@click.version_option(VERSION)
|
|
263
|
+
def cli():
|
|
264
|
+
"""Extract vocabulary using local data-only language packs."""
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@cli.group("dev", hidden=True)
|
|
268
|
+
def dev():
|
|
269
|
+
"""Build, inspect and package dictionaries for development."""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@cli.command()
|
|
273
|
+
@click.option(
|
|
274
|
+
"--language", "-l", required=True, help="Three-letter pack code, or base."
|
|
275
|
+
)
|
|
276
|
+
@click.option(
|
|
277
|
+
"--text", "-t", help="Explicit text, overriding unrelated piped input."
|
|
278
|
+
)
|
|
279
|
+
@click.option(
|
|
280
|
+
"--file",
|
|
281
|
+
"-i",
|
|
282
|
+
"input_file",
|
|
283
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
284
|
+
)
|
|
285
|
+
@click.option(
|
|
286
|
+
"--lemmatize/--no-lemmatize",
|
|
287
|
+
default=None,
|
|
288
|
+
help="Display lemmas instead of forms.",
|
|
289
|
+
)
|
|
290
|
+
@click.option(
|
|
291
|
+
"--filter-by",
|
|
292
|
+
type=click.Choice(["forms", "lemmas"]),
|
|
293
|
+
help="What the known list excludes; independent of display.",
|
|
294
|
+
)
|
|
295
|
+
@click.option("--exclude-unknown/--include-unknown", default=None)
|
|
296
|
+
@click.option("--freq/--no-freq", default=None)
|
|
297
|
+
@click.option("--context/--no-context", default=None)
|
|
298
|
+
@click.option(
|
|
299
|
+
"--exclude",
|
|
300
|
+
"exclusions",
|
|
301
|
+
multiple=True,
|
|
302
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
303
|
+
)
|
|
304
|
+
@click.option(
|
|
305
|
+
"--exclude-forms",
|
|
306
|
+
"forms_only",
|
|
307
|
+
multiple=True,
|
|
308
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
309
|
+
)
|
|
310
|
+
@click.option("--no-exclusions", is_flag=True)
|
|
311
|
+
@click.option(
|
|
312
|
+
"--known-file",
|
|
313
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
314
|
+
help="Primary known list; missing means initially empty.",
|
|
315
|
+
)
|
|
316
|
+
@click.option("--sort", type=click.Choice(SORTS))
|
|
317
|
+
@click.option(
|
|
318
|
+
"--custom-order",
|
|
319
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
320
|
+
)
|
|
321
|
+
@click.option(
|
|
322
|
+
"--sentence-pattern",
|
|
323
|
+
help="Regex matching sentence-ending delimiters (not empty boundaries).",
|
|
324
|
+
)
|
|
325
|
+
@click.option("--context-limit", type=click.IntRange(1, 20))
|
|
326
|
+
@click.option("--bold", type=click.Choice(MARKUPS))
|
|
327
|
+
@click.option("--format", "output_format", type=click.Choice(FORMATS))
|
|
328
|
+
@click.option("--prompt/--no-prompt", default=None)
|
|
329
|
+
@click.option("--src/--no-src", default=None)
|
|
330
|
+
@click.option(
|
|
331
|
+
"--save-context/--no-save-context",
|
|
332
|
+
default=None,
|
|
333
|
+
help="Explicitly save/decline surviving contexts.",
|
|
334
|
+
)
|
|
335
|
+
@click.option(
|
|
336
|
+
"--update-known/--no-update-known",
|
|
337
|
+
default=None,
|
|
338
|
+
help="Discouraged: text occurrence does not mean learned vocabulary.",
|
|
339
|
+
)
|
|
340
|
+
@click.option(
|
|
341
|
+
"--test-known",
|
|
342
|
+
is_flag=True,
|
|
343
|
+
help=(
|
|
344
|
+
"Preview proposed known additions; "
|
|
345
|
+
"write neither known list nor history."
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
@click.option("--config", type=click.Path(dir_okay=False, path_type=Path))
|
|
349
|
+
@click.option("--no-config", is_flag=True)
|
|
350
|
+
@click.option(
|
|
351
|
+
"--plugins-dir", type=click.Path(file_okay=False, path_type=Path)
|
|
352
|
+
)
|
|
353
|
+
def blitz(
|
|
354
|
+
language,
|
|
355
|
+
text,
|
|
356
|
+
input_file,
|
|
357
|
+
lemmatize,
|
|
358
|
+
filter_by,
|
|
359
|
+
exclude_unknown,
|
|
360
|
+
freq,
|
|
361
|
+
context,
|
|
362
|
+
exclusions,
|
|
363
|
+
forms_only,
|
|
364
|
+
no_exclusions,
|
|
365
|
+
known_file,
|
|
366
|
+
sort,
|
|
367
|
+
custom_order,
|
|
368
|
+
sentence_pattern,
|
|
369
|
+
context_limit,
|
|
370
|
+
bold,
|
|
371
|
+
output_format,
|
|
372
|
+
prompt,
|
|
373
|
+
src,
|
|
374
|
+
save_context,
|
|
375
|
+
update_known,
|
|
376
|
+
test_known,
|
|
377
|
+
config,
|
|
378
|
+
no_config,
|
|
379
|
+
plugins_dir,
|
|
380
|
+
):
|
|
381
|
+
"""Extract filtered vocabulary from input text."""
|
|
382
|
+
if text is not None and input_file is not None:
|
|
383
|
+
raise click.UsageError("Use either --text or --file, not both")
|
|
384
|
+
with _errors():
|
|
385
|
+
service = _service(config, no_config, plugins_dir)
|
|
386
|
+
settings = service.settings(language)
|
|
387
|
+
display = _presentation_options(
|
|
388
|
+
settings,
|
|
389
|
+
freq=freq,
|
|
390
|
+
context=context,
|
|
391
|
+
prompt=prompt,
|
|
392
|
+
src=src,
|
|
393
|
+
bold=bold,
|
|
394
|
+
format=output_format,
|
|
395
|
+
)
|
|
396
|
+
if display["prompt"] and not display["prompt_text"]:
|
|
397
|
+
raise ValueError(f"No prompt_text configured for {language}")
|
|
398
|
+
text = _read_input(text, input_file)
|
|
399
|
+
saving = _should_save_contexts(
|
|
400
|
+
settings["save_context"], save_context, test_known
|
|
401
|
+
)
|
|
402
|
+
update = (
|
|
403
|
+
settings["auto_update_known"]
|
|
404
|
+
if update_known is None
|
|
405
|
+
else update_known
|
|
406
|
+
)
|
|
407
|
+
entries = service.blitz(
|
|
408
|
+
text,
|
|
409
|
+
language,
|
|
410
|
+
lemmatize=lemmatize,
|
|
411
|
+
filter_by=filter_by,
|
|
412
|
+
context=display["context"] or saving,
|
|
413
|
+
exclude_unknown=exclude_unknown,
|
|
414
|
+
exclusions=exclusions or None,
|
|
415
|
+
forms_only=forms_only or None,
|
|
416
|
+
no_exclusions=no_exclusions,
|
|
417
|
+
known_file=known_file,
|
|
418
|
+
sort=sort,
|
|
419
|
+
custom_order=custom_order,
|
|
420
|
+
sentence_pattern=sentence_pattern,
|
|
421
|
+
context_limit=context_limit,
|
|
422
|
+
track_known=update or test_known,
|
|
423
|
+
)
|
|
424
|
+
output = _render(
|
|
425
|
+
entries,
|
|
426
|
+
output_format=display["format"],
|
|
427
|
+
freq=display["freq"],
|
|
428
|
+
context=display["context"],
|
|
429
|
+
bold=display["bold"],
|
|
430
|
+
source=text if display["src"] else None,
|
|
431
|
+
prompt=display["prompt_text"],
|
|
432
|
+
)
|
|
433
|
+
_handle_known_update(
|
|
434
|
+
service, language, entries, known_file, update, test_known
|
|
435
|
+
)
|
|
436
|
+
_handle_context_saving(service, language, entries, saving)
|
|
437
|
+
click.echo(output, nl=False)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@cli.command("list-languages")
|
|
441
|
+
@click.option("--config", type=click.Path(dir_okay=False, path_type=Path))
|
|
442
|
+
@click.option("--no-config", is_flag=True)
|
|
443
|
+
@click.option(
|
|
444
|
+
"--plugins-dir", type=click.Path(file_okay=False, path_type=Path)
|
|
445
|
+
)
|
|
446
|
+
def list_languages(config, no_config, plugins_dir):
|
|
447
|
+
"""List installed languages available for text processing."""
|
|
448
|
+
with _errors():
|
|
449
|
+
click.echo(
|
|
450
|
+
"\n".join(
|
|
451
|
+
_service(config, no_config, plugins_dir).list_languages()
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@dev.command("check-plugin")
|
|
457
|
+
@click.argument("code")
|
|
458
|
+
@click.option("--config", type=click.Path(dir_okay=False, path_type=Path))
|
|
459
|
+
@click.option("--no-config", is_flag=True)
|
|
460
|
+
@click.option(
|
|
461
|
+
"--plugins-dir", type=click.Path(file_okay=False, path_type=Path)
|
|
462
|
+
)
|
|
463
|
+
def check_plugin(code, config, no_config, plugins_dir):
|
|
464
|
+
"""Report all validation checks for an installed pack."""
|
|
465
|
+
with _errors():
|
|
466
|
+
report = _service(config, no_config, plugins_dir).check_plugin(code)
|
|
467
|
+
for item in report:
|
|
468
|
+
click.echo(f"[{item.status.upper()}] {item.name}: {item.detail}")
|
|
469
|
+
if any(x.status == "fail" for x in report):
|
|
470
|
+
raise click.ClickException(f"Pack {code} failed validation")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@dev.command("build-plugin")
|
|
474
|
+
@click.argument(
|
|
475
|
+
"source_dir", type=click.Path(exists=True, file_okay=False, path_type=Path)
|
|
476
|
+
)
|
|
477
|
+
@click.option(
|
|
478
|
+
"--database",
|
|
479
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
480
|
+
help="Import a compatible SQLite database instead of forms.tsv.",
|
|
481
|
+
)
|
|
482
|
+
@click.option(
|
|
483
|
+
"--skip-orphans",
|
|
484
|
+
is_flag=True,
|
|
485
|
+
help="Explicitly omit/count SQLite source forms with missing lemma IDs.",
|
|
486
|
+
)
|
|
487
|
+
@click.option(
|
|
488
|
+
"--skip-unsupported",
|
|
489
|
+
is_flag=True,
|
|
490
|
+
help=(
|
|
491
|
+
"Explicitly omit unsupported multiword/symbol entries; "
|
|
492
|
+
"count them in build-info.json."
|
|
493
|
+
),
|
|
494
|
+
)
|
|
495
|
+
@click.option("--config", type=click.Path(dir_okay=False, path_type=Path))
|
|
496
|
+
@click.option("--no-config", is_flag=True)
|
|
497
|
+
@click.option(
|
|
498
|
+
"--plugins-dir", type=click.Path(file_okay=False, path_type=Path)
|
|
499
|
+
)
|
|
500
|
+
def build_plugin(
|
|
501
|
+
source_dir,
|
|
502
|
+
database,
|
|
503
|
+
skip_unsupported,
|
|
504
|
+
skip_orphans,
|
|
505
|
+
config,
|
|
506
|
+
no_config,
|
|
507
|
+
plugins_dir,
|
|
508
|
+
):
|
|
509
|
+
"""Build and validate a pack before publishing it."""
|
|
510
|
+
with _errors():
|
|
511
|
+
path = _service(config, no_config, plugins_dir).build_plugin(
|
|
512
|
+
source_dir,
|
|
513
|
+
source_database=database,
|
|
514
|
+
skip_unsupported=skip_unsupported,
|
|
515
|
+
skip_orphans=skip_orphans,
|
|
516
|
+
)
|
|
517
|
+
click.echo(str(path))
|
|
518
|
+
click.echo(
|
|
519
|
+
(path / "build-info.json").read_text(encoding="utf-8"),
|
|
520
|
+
err=True,
|
|
521
|
+
nl=False,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@dev.command("expand-plugin")
|
|
526
|
+
@click.argument("code")
|
|
527
|
+
@click.argument(
|
|
528
|
+
"additional", type=click.Path(exists=True, file_okay=False, path_type=Path)
|
|
529
|
+
)
|
|
530
|
+
@click.option("--pack-version", default="0.2.0", show_default=True)
|
|
531
|
+
@click.option("--config", type=click.Path(dir_okay=False, path_type=Path))
|
|
532
|
+
@click.option("--no-config", is_flag=True)
|
|
533
|
+
@click.option(
|
|
534
|
+
"--plugins-dir", type=click.Path(file_okay=False, path_type=Path)
|
|
535
|
+
)
|
|
536
|
+
def expand_plugin(
|
|
537
|
+
code, additional, pack_version, config, no_config, plugins_dir
|
|
538
|
+
):
|
|
539
|
+
"""Expand a pack while preserving all existing dictionary pairs."""
|
|
540
|
+
with _errors():
|
|
541
|
+
service = _service(config, no_config, plugins_dir)
|
|
542
|
+
stats = service.expand_plugin(code, additional, version=pack_version)
|
|
543
|
+
click.echo(json.dumps(stats, indent=2))
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@cli.command("install-plugin")
|
|
547
|
+
@click.argument("source", metavar="CODE_OR_DIRECTORY")
|
|
548
|
+
@click.option(
|
|
549
|
+
"--replace",
|
|
550
|
+
is_flag=True,
|
|
551
|
+
help="Replace an installed pack with the selected version.",
|
|
552
|
+
)
|
|
553
|
+
@click.option("--config", type=click.Path(dir_okay=False, path_type=Path))
|
|
554
|
+
@click.option("--no-config", is_flag=True)
|
|
555
|
+
@click.option(
|
|
556
|
+
"--plugins-dir", type=click.Path(file_okay=False, path_type=Path)
|
|
557
|
+
)
|
|
558
|
+
def install_plugin(source, replace, config, no_config, plugins_dir):
|
|
559
|
+
"""Install a registered language or a local directory."""
|
|
560
|
+
with _errors():
|
|
561
|
+
if source in REGISTRY and not Path(source).is_dir():
|
|
562
|
+
name = REGISTRY[source]["name"]
|
|
563
|
+
click.echo(
|
|
564
|
+
f"Downloading and validating {name} from GitHub...", err=True
|
|
565
|
+
)
|
|
566
|
+
click.echo(
|
|
567
|
+
str(
|
|
568
|
+
_service(config, no_config, plugins_dir).install_plugin(
|
|
569
|
+
source, replace=replace
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@dev.command("package-plugin")
|
|
576
|
+
@click.argument("code")
|
|
577
|
+
@click.option(
|
|
578
|
+
"--output-dir",
|
|
579
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
580
|
+
default="release-assets",
|
|
581
|
+
show_default=True,
|
|
582
|
+
)
|
|
583
|
+
@click.option("--config", type=click.Path(dir_okay=False, path_type=Path))
|
|
584
|
+
@click.option("--no-config", is_flag=True)
|
|
585
|
+
@click.option(
|
|
586
|
+
"--plugins-dir", type=click.Path(file_okay=False, path_type=Path)
|
|
587
|
+
)
|
|
588
|
+
def package_plugin(code, output_dir, config, no_config, plugins_dir):
|
|
589
|
+
"""Prepare a validated pack archive for publication."""
|
|
590
|
+
with _errors():
|
|
591
|
+
service = _service(config, no_config, plugins_dir)
|
|
592
|
+
click.echo(str(service.package_plugin(code, output_dir)))
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
@cli.command("remove-plugin")
|
|
596
|
+
@click.argument("code")
|
|
597
|
+
@click.option("--yes", is_flag=True, help="Skip the removal confirmation.")
|
|
598
|
+
@click.option("--config", type=click.Path(dir_okay=False, path_type=Path))
|
|
599
|
+
@click.option("--no-config", is_flag=True)
|
|
600
|
+
@click.option(
|
|
601
|
+
"--plugins-dir", type=click.Path(file_okay=False, path_type=Path)
|
|
602
|
+
)
|
|
603
|
+
def remove_plugin(code, yes, config, no_config, plugins_dir):
|
|
604
|
+
"""Remove an installed language pack."""
|
|
605
|
+
with _errors():
|
|
606
|
+
service = _service(config, no_config, plugins_dir)
|
|
607
|
+
# Validate before constructing a path or asking to remove it.
|
|
608
|
+
service.settings(code)
|
|
609
|
+
if not yes:
|
|
610
|
+
click.confirm(
|
|
611
|
+
f"Remove pack {service.plugins_dir / code}?",
|
|
612
|
+
abort=True,
|
|
613
|
+
err=True,
|
|
614
|
+
)
|
|
615
|
+
service.remove_plugin(code)
|
|
616
|
+
click.echo(f"Removed {code}")
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
@cli.command("cleanup-known")
|
|
620
|
+
@click.argument("code")
|
|
621
|
+
@click.argument(
|
|
622
|
+
"path", type=click.Path(exists=True, dir_okay=False, path_type=Path)
|
|
623
|
+
)
|
|
624
|
+
@click.option(
|
|
625
|
+
"--apply",
|
|
626
|
+
is_flag=True,
|
|
627
|
+
help="Normalize/deduplicate in place, preserving PATH.bak.",
|
|
628
|
+
)
|
|
629
|
+
@click.option("--config", type=click.Path(dir_okay=False, path_type=Path))
|
|
630
|
+
@click.option("--no-config", is_flag=True)
|
|
631
|
+
@click.option(
|
|
632
|
+
"--plugins-dir", type=click.Path(file_okay=False, path_type=Path)
|
|
633
|
+
)
|
|
634
|
+
def cleanup_known(code, path, apply, config, no_config, plugins_dir):
|
|
635
|
+
"""Preview or apply known-list deduplication."""
|
|
636
|
+
with _errors():
|
|
637
|
+
result = _service(config, no_config, plugins_dir).cleanup_known(
|
|
638
|
+
code, path, apply=apply
|
|
639
|
+
)
|
|
640
|
+
click.echo(json.dumps(result, ensure_ascii=False, indent=2))
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@cli.command()
|
|
644
|
+
@click.argument("code")
|
|
645
|
+
@click.argument("term", required=False)
|
|
646
|
+
@click.option(
|
|
647
|
+
"--limit", type=click.IntRange(1), default=100, show_default=True
|
|
648
|
+
)
|
|
649
|
+
@click.option("--config", type=click.Path(dir_okay=False, path_type=Path))
|
|
650
|
+
@click.option("--no-config", is_flag=True)
|
|
651
|
+
@click.option(
|
|
652
|
+
"--plugins-dir", type=click.Path(file_okay=False, path_type=Path)
|
|
653
|
+
)
|
|
654
|
+
def history(code, term, limit, config, no_config, plugins_dir):
|
|
655
|
+
"""Show saved sentences containing encountered words."""
|
|
656
|
+
with _errors():
|
|
657
|
+
click.echo(
|
|
658
|
+
json.dumps(
|
|
659
|
+
_service(config, no_config, plugins_dir).history(
|
|
660
|
+
code, term, limit=limit
|
|
661
|
+
),
|
|
662
|
+
ensure_ascii=False,
|
|
663
|
+
indent=2,
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
if __name__ == "__main__":
|
|
669
|
+
cli()
|