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.
- clustergrep/__init__.py +3 -0
- clustergrep/__main__.py +4 -0
- clustergrep/cli.py +570 -0
- clustergrep/cluster.py +129 -0
- clustergrep/matcher.py +178 -0
- clustergrep/paths.py +97 -0
- clustergrep/thesaurus.py +112 -0
- clustergrep/vectors.py +165 -0
- clustergrep/wordnet.py +344 -0
- clustergrep-0.8.0.dist-info/METADATA +285 -0
- clustergrep-0.8.0.dist-info/RECORD +14 -0
- clustergrep-0.8.0.dist-info/WHEEL +4 -0
- clustergrep-0.8.0.dist-info/entry_points.txt +2 -0
- clustergrep-0.8.0.dist-info/licenses/LICENSE +21 -0
clustergrep/__init__.py
ADDED
clustergrep/__main__.py
ADDED
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())
|