clustergrep 0.8.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.
@@ -0,0 +1,3 @@
1
+ """grep for a concept rather than a string."""
2
+
3
+ __version__ = "0.8.0"
@@ -0,0 +1,4 @@
1
+ import sys
2
+ from .cli import main
3
+
4
+ sys.exit(main())
clustergrep/cli.py ADDED
@@ -0,0 +1,570 @@
1
+ """Command line interface.
2
+
3
+ Deliberately grep-shaped: same flag names where the meaning is the same, same
4
+ exit codes (0 matched, 1 nothing matched, 2 error), same file:line: prefix.
5
+ The one place it departs is that a match carries two extra fields -- how far
6
+ the matching term sits from your query, and which term it was -- because a
7
+ result you cannot calibrate is worse than no result.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import fnmatch
14
+ import json
15
+ import os
16
+ import sys
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Iterable, Iterator, Sequence, TextIO
20
+
21
+ from . import __version__
22
+ from .cluster import Backend, BackendError, Cluster
23
+ from .matcher import Match, Matcher
24
+
25
+ EXIT_MATCH = 0
26
+ EXIT_NO_MATCH = 1
27
+ EXIT_ERROR = 2
28
+
29
+ DEFAULT_THRESHOLD = 0.4
30
+ DEFAULT_MAX_TERMS = 250
31
+
32
+ # Read this much of a file to decide whether it is text, as grep does.
33
+ _SNIFF = 8192
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ p = argparse.ArgumentParser(
38
+ prog="clustergrep",
39
+ description="grep for a concept: match a word and the words that mean "
40
+ "roughly the same thing, reporting how far each match sits from what "
41
+ "you asked for.",
42
+ epilog=(
43
+ "Matching is always word-oriented, like grep -w, because concepts "
44
+ "are words.\nWith --threshold 0 no semantic expansion happens at "
45
+ "all, so\n\n clustergrep -t 0 --no-inflect -s WORD FILE\n\nis "
46
+ "exactly grep -w -F WORD FILE."
47
+ ),
48
+ formatter_class=argparse.RawDescriptionHelpFormatter,
49
+ )
50
+ p.add_argument("word", nargs="?", help="the concept to search for")
51
+ p.add_argument("files", nargs="*", help="files to search; omit to read stdin")
52
+
53
+ g = p.add_argument_group("cluster")
54
+ g.add_argument(
55
+ "-t", "--threshold", type=float, default=DEFAULT_THRESHOLD,
56
+ metavar="D",
57
+ help=f"largest distance to accept, 0.0-1.0 (default {DEFAULT_THRESHOLD}); "
58
+ "0 matches only the word itself",
59
+ )
60
+ g.add_argument(
61
+ "-b", "--backend", choices=("wordnet", "vectors", "thesaurus"),
62
+ default=os.environ.get("CLUSTERGREP_BACKEND", "wordnet"),
63
+ help="where the cluster comes from (default wordnet)",
64
+ )
65
+ g.add_argument("--model", default=os.environ.get("CLUSTERGREP_MODEL"),
66
+ metavar="PATH", help="vector model for --backend vectors")
67
+ g.add_argument("--thesaurus", default=os.environ.get("CLUSTERGREP_THESAURUS"),
68
+ metavar="PATH", help="TSV file for --backend thesaurus")
69
+ g.add_argument("--pos", choices=("n", "v", "a", "r"),
70
+ help="restrict to one part of speech (noun/verb/adj/adverb)")
71
+ g.add_argument("--sense", type=int, metavar="N",
72
+ help="use only WordNet sense N; see --senses")
73
+ g.add_argument("--sense-penalty", type=float, default=None, metavar="D",
74
+ help="extra distance per less-common sense (default 0.05)")
75
+ g.add_argument("--antonyms", action="store_true",
76
+ help="include opposites in the cluster")
77
+ g.add_argument("--max-terms", type=int, default=DEFAULT_MAX_TERMS, metavar="N",
78
+ help=f"cap the cluster size (default {DEFAULT_MAX_TERMS})")
79
+ inf = g.add_mutually_exclusive_group()
80
+ inf.add_argument("--inflect", dest="inflect", action="store_true", default=True,
81
+ help="also match inflections: escaped, fled (the default)")
82
+ inf.add_argument("--no-inflect", dest="inflect", action="store_false",
83
+ help="match only the exact surface forms in the cluster")
84
+
85
+ g = p.add_argument_group("inspect the cluster instead of searching")
86
+ g.add_argument("--explain", action="store_true",
87
+ help="print the cluster and exit, without searching")
88
+ g.add_argument("--tsv", action="store_true",
89
+ help="with --explain, emit thesaurus TSV to pin and edit")
90
+ g.add_argument("--senses", action="store_true",
91
+ help="list the word's WordNet senses and exit")
92
+
93
+ g = p.add_argument_group("matching")
94
+ case = g.add_mutually_exclusive_group()
95
+ case.add_argument("-i", "--ignore-case", dest="ignore_case",
96
+ action="store_true", default=True,
97
+ help="case-insensitive matching (the default)")
98
+ case.add_argument("-s", "--case-sensitive", dest="ignore_case",
99
+ action="store_false", help="case-sensitive matching")
100
+ g.add_argument("-v", "--invert-match", action="store_true",
101
+ help="print lines with no match in the cluster")
102
+ g.add_argument("-m", "--max-count", type=int, metavar="N",
103
+ help="stop after N matching lines per file")
104
+ g.add_argument("-r", "-R", "--recursive", action="store_true",
105
+ help="search directories recursively")
106
+ g.add_argument("--include", action="append", metavar="GLOB", default=[],
107
+ help="only search files matching GLOB (repeatable)")
108
+ g.add_argument("--exclude", action="append", metavar="GLOB", default=[],
109
+ help="skip files matching GLOB (repeatable)")
110
+
111
+ g = p.add_argument_group("output")
112
+ g.add_argument("-c", "--count", action="store_true",
113
+ help="print a count of matching lines per file")
114
+ g.add_argument("-l", "--files-with-matches", action="store_true",
115
+ help="print only the names of files that matched")
116
+ g.add_argument("-L", "--files-without-match", action="store_true",
117
+ help="print only the names of files that did not match")
118
+ g.add_argument("-o", "--only-matching", action="store_true",
119
+ help="print only the matched text")
120
+ num = g.add_mutually_exclusive_group()
121
+ num.add_argument("-n", "--line-number", dest="line_number",
122
+ action="store_true", default=True,
123
+ help="prefix with line number (the default)")
124
+ num.add_argument("-N", "--no-line-number", dest="line_number",
125
+ action="store_false", help="omit line numbers")
126
+ g.add_argument("-H", "--with-filename", dest="filename", action="store_true",
127
+ default=None, help="always prefix with the file name")
128
+ g.add_argument("--no-filename", dest="filename", action="store_false",
129
+ help="never prefix with the file name")
130
+ g.add_argument("--no-distance", dest="show_distance", action="store_false",
131
+ help="omit the distance and matched-term columns")
132
+ g.add_argument("--sort", action="store_true",
133
+ help="buffer output and print nearest matches first")
134
+ g.add_argument("--json", action="store_true",
135
+ help="emit one JSON object per match")
136
+ g.add_argument("--stats", action="store_true",
137
+ help="after searching, report which terms actually fired")
138
+ g.add_argument("--color", choices=("auto", "always", "never"), default="auto",
139
+ help="colourise output (default auto)")
140
+
141
+ p.add_argument("--install-data", action="store_true",
142
+ help="download the WordNet corpus (~10MB, once), then exit")
143
+ p.add_argument("--paths", action="store_true",
144
+ help="show where downloaded data and caches live, then exit")
145
+ p.add_argument("--version", action="version", version=f"clustergrep {__version__}")
146
+ return p
147
+
148
+
149
+ def parse_argv(parser: argparse.ArgumentParser, argv: Sequence[str] | None):
150
+ """Parse arguments, tolerating options interleaved with file names.
151
+
152
+ argparse matches positionals in contiguous runs, so an option sitting
153
+ between two of them splits the run and the second group has nothing left
154
+ to match:
155
+
156
+ clustergrep escape a.log --stats b.log
157
+
158
+ That fails on every Python version, and before 3.12 even a single option
159
+ between the word and one file is enough to break it. Since grep accepts
160
+ its options anywhere, so must this.
161
+
162
+ So positionals are recovered rather than matched: anything argparse could
163
+ not place is a file name, unless it looks like a flag, in which case it is
164
+ a typo and still deserves the usual error rather than being silently
165
+ searched for on disk.
166
+ """
167
+ args, extra = parser.parse_known_args(argv)
168
+ unknown = [a for a in extra if _looks_like_flag(a)]
169
+ if unknown:
170
+ parser.error(f"unrecognized arguments: {' '.join(unknown)}")
171
+ args.files.extend(a for a in extra if not _looks_like_flag(a))
172
+ return args
173
+
174
+
175
+ def _looks_like_flag(token: str) -> bool:
176
+ # A bare "-" is conventionally a file name (stdin), not an option.
177
+ return token.startswith("-") and token != "-"
178
+
179
+
180
+ # ---------------------------------------------------------------- colour
181
+
182
+
183
+ class Ink:
184
+ """ANSI colouring, or nothing at all when the output is not a terminal."""
185
+
186
+ def __init__(self, enabled: bool) -> None:
187
+ self.enabled = enabled
188
+
189
+ def _wrap(self, code: str, text: str) -> str:
190
+ return f"\033[{code}m{text}\033[0m" if self.enabled else text
191
+
192
+ def path(self, t: str) -> str:
193
+ return self._wrap("35", t)
194
+
195
+ def lineno(self, t: str) -> str:
196
+ return self._wrap("32", t)
197
+
198
+ def sep(self, t: str) -> str:
199
+ return self._wrap("36", t)
200
+
201
+ def hit(self, t: str) -> str:
202
+ return self._wrap("1;31", t)
203
+
204
+ def distance(self, value: float, t: str) -> str:
205
+ # Graded so that a wall of results reads at a glance: near matches are
206
+ # calm, far ones announce themselves as worth double-checking.
207
+ code = "32" if value <= 0.2 else "33" if value <= 0.4 else "31"
208
+ return self._wrap(code, t)
209
+
210
+ def dim(self, t: str) -> str:
211
+ return self._wrap("2", t)
212
+
213
+
214
+ # ---------------------------------------------------------------- searching
215
+
216
+
217
+ @dataclass
218
+ class LineHit:
219
+ path: str
220
+ lineno: int
221
+ line: str
222
+ matches: list[Match]
223
+
224
+ @property
225
+ def best(self) -> Match | None:
226
+ return min(self.matches, key=lambda m: m.distance, default=None)
227
+
228
+
229
+ def search_stream(
230
+ stream: Iterable[str],
231
+ matcher: Matcher,
232
+ path: str,
233
+ *,
234
+ invert: bool,
235
+ limit: int | None,
236
+ need_matches: bool = True,
237
+ ) -> Iterator[LineHit]:
238
+ """Yield the lines of ``stream`` that match, or that do not under ``invert``.
239
+
240
+ ``need_matches`` is the difference between asking "where are all the hits
241
+ on this line, and which terms were they" and asking "is there one". The
242
+ counting and file-listing modes only need the second question answered,
243
+ and on a large file that is most of the work.
244
+ """
245
+ probe = matcher.pattern.search
246
+ found = 0
247
+ for lineno, raw in enumerate(stream, 1):
248
+ line = raw.rstrip("\n").rstrip("\r")
249
+ if need_matches:
250
+ matches = matcher.search(line)
251
+ hit = bool(matches)
252
+ else:
253
+ matches = []
254
+ hit = probe(line) is not None
255
+ if hit == invert:
256
+ continue
257
+ yield LineHit(path=path, lineno=lineno, line=line, matches=matches)
258
+ found += 1
259
+ if limit is not None and found >= limit:
260
+ return
261
+
262
+
263
+ def looks_binary(path: Path) -> bool:
264
+ try:
265
+ with path.open("rb") as fh:
266
+ return b"\0" in fh.read(_SNIFF)
267
+ except OSError:
268
+ return False
269
+
270
+
271
+ def walk(files: Sequence[str], *, recursive: bool, include: list[str],
272
+ exclude: list[str], warn) -> Iterator[Path]:
273
+ def wanted(p: Path) -> bool:
274
+ if include and not any(fnmatch.fnmatch(p.name, g) for g in include):
275
+ return False
276
+ return not any(fnmatch.fnmatch(p.name, g) for g in exclude)
277
+
278
+ for name in files:
279
+ path = Path(name)
280
+ if path.is_dir():
281
+ if not recursive:
282
+ warn(f"{name}: is a directory")
283
+ continue
284
+ for root, dirs, names in os.walk(path):
285
+ dirs[:] = sorted(d for d in dirs if d != ".git")
286
+ for child in sorted(names):
287
+ candidate = Path(root) / child
288
+ if wanted(candidate):
289
+ yield candidate
290
+ elif path.exists():
291
+ yield path
292
+ else:
293
+ warn(f"{name}: no such file or directory")
294
+
295
+
296
+ # ---------------------------------------------------------------- rendering
297
+
298
+
299
+ class Printer:
300
+ """Formats hits. Holds every decision about what a result line looks like."""
301
+
302
+ def __init__(self, args, ink: Ink, out: TextIO) -> None:
303
+ self.args = args
304
+ self.ink = ink
305
+ self.out = out
306
+ self.show_path = args.filename
307
+
308
+ def _prefix(self, hit: LineHit, match: Match | None) -> str:
309
+ sep = self.ink.sep(":")
310
+ parts = []
311
+ if self.show_path:
312
+ parts.append(self.ink.path(hit.path))
313
+ if self.args.line_number:
314
+ parts.append(self.ink.lineno(str(hit.lineno)))
315
+ if self.args.show_distance and match is not None:
316
+ parts.append(self.ink.distance(match.distance, f"{match.distance:.2f}"))
317
+ parts.append(self.ink.distance(match.distance, match.term.text))
318
+ return f"{sep.join(parts)}{sep}" if parts else ""
319
+
320
+ def _highlight(self, hit: LineHit) -> str:
321
+ if not self.ink.enabled or not hit.matches:
322
+ return hit.line
323
+ out, cursor = [], 0
324
+ for m in sorted(hit.matches, key=lambda m: m.start):
325
+ if m.start < cursor: # overlapping match, already painted
326
+ continue
327
+ out.append(hit.line[cursor:m.start])
328
+ out.append(self.ink.hit(hit.line[m.start:m.end]))
329
+ cursor = m.end
330
+ out.append(hit.line[cursor:])
331
+ return "".join(out)
332
+
333
+ def emit(self, hit: LineHit) -> None:
334
+ if self.args.json:
335
+ for m in hit.matches or [None]:
336
+ self.out.write(json.dumps({
337
+ "file": hit.path,
338
+ "line": hit.lineno,
339
+ "distance": None if m is None else round(m.distance, 4),
340
+ "term": None if m is None else m.term.text,
341
+ "matched": None if m is None else m.text,
342
+ "text": hit.line,
343
+ }) + "\n")
344
+ return
345
+ if self.args.only_matching:
346
+ for m in sorted(hit.matches, key=lambda m: m.start):
347
+ self.out.write(f"{self._prefix(hit, m)}{self.ink.hit(m.text)}\n")
348
+ return
349
+ self.out.write(f"{self._prefix(hit, hit.best)}{self._highlight(hit)}\n")
350
+
351
+
352
+ def render_explain(cluster: Cluster, ink: Ink, out: TextIO) -> None:
353
+ out.write(
354
+ f"{cluster.query!r} via {cluster.backend}, threshold {cluster.threshold:g}: "
355
+ f"{len(cluster.terms)} term(s)\n"
356
+ )
357
+ width = max((len(t.text) for t in cluster.terms), default=0)
358
+ for term in cluster.terms:
359
+ line = f" {ink.distance(term.distance, f'{term.distance:.2f}')} {term.text:<{width}}"
360
+ if term.via and term.via != "query":
361
+ line += f" {ink.dim(term.via)}"
362
+ out.write(line.rstrip() + "\n")
363
+
364
+
365
+ def render_senses(backend, word: str, out: TextIO) -> int:
366
+ senses = backend.describe_senses(word)
367
+ if not senses:
368
+ out.write(f"{word!r} is not in WordNet\n")
369
+ return EXIT_NO_MATCH
370
+ for index, name, gloss, lemmas in senses:
371
+ out.write(f"{index:>3} {name:<24} {', '.join(lemmas)}\n {gloss}\n")
372
+ return EXIT_MATCH
373
+
374
+
375
+ # ---------------------------------------------------------------- assembly
376
+
377
+
378
+ def build_backend(args) -> Backend:
379
+ if args.backend == "wordnet":
380
+ from .wordnet import SENSE_PENALTY, WordNetBackend
381
+
382
+ return WordNetBackend(
383
+ pos=args.pos,
384
+ sense=args.sense,
385
+ sense_penalty=(
386
+ SENSE_PENALTY if args.sense_penalty is None else args.sense_penalty
387
+ ),
388
+ include_antonyms=args.antonyms,
389
+ )
390
+ if args.backend == "thesaurus":
391
+ if not args.thesaurus:
392
+ raise BackendError(
393
+ "--backend thesaurus needs a file",
394
+ remedy="pass --thesaurus PATH or set CLUSTERGREP_THESAURUS",
395
+ )
396
+ from .thesaurus import ThesaurusBackend
397
+
398
+ return ThesaurusBackend(args.thesaurus)
399
+
400
+ if not args.model:
401
+ raise BackendError(
402
+ "--backend vectors needs a model",
403
+ remedy="pass --model PATH or set CLUSTERGREP_MODEL",
404
+ )
405
+ from .vectors import VectorBackend
406
+
407
+ return VectorBackend(args.model)
408
+
409
+
410
+ def main(argv: Sequence[str] | None = None) -> int:
411
+ parser = build_parser()
412
+ args = parse_argv(parser, argv)
413
+ out, err = sys.stdout, sys.stderr
414
+
415
+ if args.paths:
416
+ from .paths import describe
417
+
418
+ out.write(describe())
419
+ return EXIT_MATCH
420
+ if args.install_data:
421
+ from .wordnet import install_data
422
+
423
+ ok, message = install_data()
424
+ (out if ok else err).write(message + "\n")
425
+ return EXIT_MATCH if ok else EXIT_ERROR
426
+ if args.word is None:
427
+ parser.error("a word to search for is required")
428
+ if not 0.0 <= args.threshold <= 1.0:
429
+ parser.error(f"--threshold must be between 0.0 and 1.0, got {args.threshold}")
430
+
431
+ ink = Ink(args.color == "always" or (args.color == "auto" and out.isatty()))
432
+
433
+ def warn(message: str) -> None:
434
+ err.write(f"clustergrep: {message}\n")
435
+
436
+ try:
437
+ backend = build_backend(args)
438
+ if args.senses:
439
+ if not hasattr(backend, "describe_senses"):
440
+ parser.error(f"--senses is only meaningful for --backend wordnet")
441
+ return render_senses(backend, args.word, out)
442
+
443
+ cluster = Cluster.build(
444
+ query=args.word,
445
+ backend=backend.name,
446
+ terms=backend.expand(args.word, args.threshold),
447
+ threshold=args.threshold,
448
+ max_terms=args.max_terms,
449
+ )
450
+ except BackendError as exc:
451
+ warn(str(exc))
452
+ if exc.remedy:
453
+ err.write(f" try: {exc.remedy}\n")
454
+ return EXIT_ERROR
455
+ except ValueError as exc:
456
+ warn(str(exc))
457
+ return EXIT_ERROR
458
+
459
+ if args.explain:
460
+ if args.tsv:
461
+ from .thesaurus import to_tsv
462
+
463
+ out.write(to_tsv(cluster))
464
+ else:
465
+ render_explain(cluster, ink, out)
466
+ return EXIT_MATCH
467
+
468
+ if len(cluster.terms) == 1 and args.threshold > 0:
469
+ warn(
470
+ f"{args.word!r} has no neighbours within {args.threshold:g} "
471
+ f"in {backend.name}; searching for the word alone"
472
+ )
473
+
474
+ # Inflection is morphology, not semantics: "escaped" is the same word as
475
+ # "escape", not a more distant one. So it stays on its own axis and does
476
+ # not quietly switch itself off when --threshold is 0.
477
+ matcher = Matcher(
478
+ cluster,
479
+ inflect=args.inflect,
480
+ ignore_case=args.ignore_case,
481
+ word_variants=getattr(backend, "word_variants", None),
482
+ )
483
+
484
+ return run_search(args, matcher, ink, out, warn)
485
+
486
+
487
+ def run_search(args, matcher: Matcher, ink: Ink, out: TextIO, warn) -> int:
488
+ from collections import Counter
489
+
490
+ if args.files:
491
+ paths: list[Path | None] = list(
492
+ walk(args.files, recursive=args.recursive, include=args.include,
493
+ exclude=args.exclude, warn=warn)
494
+ )
495
+ else:
496
+ paths = [None]
497
+
498
+ if args.filename is None:
499
+ args.filename = len(paths) > 1 or args.recursive
500
+
501
+ # --stats is a claim about which terms fired, so it forces the slow path.
502
+ summarising = args.count or args.files_with_matches or args.files_without_match
503
+ need_matches = args.stats or not (summarising or args.invert_match)
504
+
505
+ printer = Printer(args, ink, out)
506
+ fired: Counter = Counter()
507
+ matched_any = False
508
+ buffered: list[LineHit] = []
509
+
510
+ for path in paths:
511
+ label = "(standard input)" if path is None else str(path)
512
+ if path is not None and looks_binary(path):
513
+ continue
514
+ try:
515
+ handle = (
516
+ sys.stdin if path is None
517
+ else path.open(encoding="utf-8", errors="replace")
518
+ )
519
+ except OSError as exc:
520
+ warn(f"{label}: {exc.strerror}")
521
+ continue
522
+
523
+ count = 0
524
+ try:
525
+ for hit in search_stream(handle, matcher, label,
526
+ invert=args.invert_match, limit=args.max_count):
527
+ count += 1
528
+ matched_any = True
529
+ for m in hit.matches:
530
+ fired[m.term.text] += 1
531
+ if summarising:
532
+ if args.files_with_matches:
533
+ out.write(f"{ink.path(label)}\n")
534
+ break
535
+ if args.files_without_match:
536
+ break
537
+ continue
538
+ if args.sort:
539
+ buffered.append(hit)
540
+ else:
541
+ printer.emit(hit)
542
+ finally:
543
+ if path is not None:
544
+ handle.close()
545
+
546
+ if args.count:
547
+ prefix = f"{ink.path(label)}{ink.sep(':')}" if args.filename else ""
548
+ out.write(f"{prefix}{count}\n")
549
+ if args.files_without_match and count == 0:
550
+ out.write(f"{ink.path(label)}\n")
551
+
552
+ if args.sort:
553
+ buffered.sort(key=lambda h: (
554
+ h.best.distance if h.best else 1.0, h.path, h.lineno))
555
+ for hit in buffered:
556
+ printer.emit(hit)
557
+
558
+ if args.stats:
559
+ sys.stderr.write(f"\n{sum(fired.values())} match(es) from "
560
+ f"{len(fired)} of {len(matcher.cluster.terms)} cluster term(s)\n")
561
+ distances = matcher.cluster.distances()
562
+ for term, n in fired.most_common():
563
+ sys.stderr.write(f" {distances.get(term, float('nan')):.2f} "
564
+ f"{term:<24} {n}\n")
565
+
566
+ return EXIT_MATCH if matched_any else EXIT_NO_MATCH
567
+
568
+
569
+ if __name__ == "__main__": # pragma: no cover
570
+ sys.exit(main())