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 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()