everything-mcp 1.0.2__py3-none-any.whl → 1.0.4__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.
- everything_mcp/__init__.py +14 -14
- everything_mcp/backend.py +455 -455
- everything_mcp/config.py +253 -253
- everything_mcp/server.py +745 -745
- {everything_mcp-1.0.2.dist-info → everything_mcp-1.0.4.dist-info}/METADATA +13 -13
- everything_mcp-1.0.4.dist-info/RECORD +11 -0
- everything_mcp-1.0.2.dist-info/RECORD +0 -11
- {everything_mcp-1.0.2.dist-info → everything_mcp-1.0.4.dist-info}/WHEEL +0 -0
- {everything_mcp-1.0.2.dist-info → everything_mcp-1.0.4.dist-info}/entry_points.txt +0 -0
- {everything_mcp-1.0.2.dist-info → everything_mcp-1.0.4.dist-info}/licenses/LICENSE +0 -0
everything_mcp/backend.py
CHANGED
|
@@ -1,455 +1,455 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Backend for communicating with voidtools Everything via es.exe.
|
|
3
|
-
|
|
4
|
-
Handles query execution, result parsing, and metadata enrichment.
|
|
5
|
-
|
|
6
|
-
Design decision: es.exe is invoked *without* ``-size -dm -dc`` flags.
|
|
7
|
-
This produces clean one-path-per-line output that is trivially parseable
|
|
8
|
-
regardless of es.exe version, locale, or output encoding. Metadata is
|
|
9
|
-
then enriched via ``os.stat()``
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import asyncio
|
|
15
|
-
import contextlib
|
|
16
|
-
import locale
|
|
17
|
-
import logging
|
|
18
|
-
import subprocess
|
|
19
|
-
from dataclasses import dataclass
|
|
20
|
-
from datetime import datetime
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
from everything_mcp.config import EverythingConfig
|
|
24
|
-
|
|
25
|
-
__all__ = [
|
|
26
|
-
"EverythingBackend",
|
|
27
|
-
"SearchResult",
|
|
28
|
-
"build_type_query",
|
|
29
|
-
"build_recent_query",
|
|
30
|
-
"human_size",
|
|
31
|
-
"FILE_TYPES",
|
|
32
|
-
"SORT_MAP",
|
|
33
|
-
"TIME_PERIODS",
|
|
34
|
-
]
|
|
35
|
-
|
|
36
|
-
logger = logging.getLogger("everything_mcp")
|
|
37
|
-
|
|
38
|
-
# ── Constants ─────────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
# Friendly sort names → es.exe -sort values
|
|
41
|
-
SORT_MAP: dict[str, str] = {
|
|
42
|
-
"name": "name",
|
|
43
|
-
"name-desc": "name-descending",
|
|
44
|
-
"path": "path",
|
|
45
|
-
"path-desc": "path-descending",
|
|
46
|
-
"size": "size",
|
|
47
|
-
"size-asc": "size",
|
|
48
|
-
"size-desc": "size-descending",
|
|
49
|
-
"date-modified": "date-modified",
|
|
50
|
-
"date-modified-asc": "date-modified",
|
|
51
|
-
"date-modified-desc": "date-modified-descending",
|
|
52
|
-
"date-created": "date-created",
|
|
53
|
-
"date-created-asc": "date-created",
|
|
54
|
-
"date-created-desc": "date-created-descending",
|
|
55
|
-
"extension": "extension",
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
# File type categories → Everything ext: queries
|
|
59
|
-
FILE_TYPES: dict[str, str] = {
|
|
60
|
-
"audio": "ext:mp3;wav;flac;aac;ogg;wma;m4a;opus;aiff;alac",
|
|
61
|
-
"video": "ext:mp4;avi;mkv;mov;wmv;flv;webm;m4v;mpeg;mpg;3gp;ts",
|
|
62
|
-
"image": "ext:jpg;jpeg;png;gif;bmp;svg;webp;tiff;tif;ico;raw;heic;heif;avif;psd",
|
|
63
|
-
"document": "ext:pdf;doc;docx;xls;xlsx;ppt;pptx;odt;ods;odp;rtf;txt;md;epub;pages;numbers;key",
|
|
64
|
-
"code": (
|
|
65
|
-
"ext:py;js;ts;jsx;tsx;c;cpp;h;hpp;cs;java;go;rs;rb;php;swift;kt;scala;r;"
|
|
66
|
-
"lua;sh;bash;ps1;bat;cmd;sql;html;css;scss;sass;less;vue;svelte;dart;zig;"
|
|
67
|
-
"nim;hx;ex;exs;erl;hs;ml;fs;clj;lisp;asm;toml;yaml;yml;json;xml;ini;cfg;"
|
|
68
|
-
"conf;env;dockerfile;makefile;cmake;gradle;sbt;proto;graphql;tf;hcl"
|
|
69
|
-
),
|
|
70
|
-
"archive": "ext:zip;rar;7z;tar;gz;bz2;xz;tgz;zst;lz4;cab;iso;dmg",
|
|
71
|
-
"executable": "ext:exe;msi;dll;sys;com;scr;appx;msix",
|
|
72
|
-
"font": "ext:ttf;otf;woff;woff2;eot;fon",
|
|
73
|
-
"3d": "ext:obj;fbx;stl;blend;dae;3ds;gltf;glb;usd;usda;usdz;step;iges",
|
|
74
|
-
"data": "ext:csv;tsv;json;jsonl;ndjson;xml;sqlite;db;mdb;accdb;parquet;arrow;avro;hdf5;feather",
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
# Time period shortcuts → Everything dm: values
|
|
78
|
-
TIME_PERIODS: dict[str, str] = {
|
|
79
|
-
"1min": "last1min",
|
|
80
|
-
"5min": "last5mins",
|
|
81
|
-
"10min": "last10mins",
|
|
82
|
-
"15min": "last15mins",
|
|
83
|
-
"30min": "last30mins",
|
|
84
|
-
"1hour": "last1hour",
|
|
85
|
-
"2hours": "last2hours",
|
|
86
|
-
"6hours": "last6hours",
|
|
87
|
-
"12hours": "last12hours",
|
|
88
|
-
"today": "today",
|
|
89
|
-
"yesterday": "yesterday",
|
|
90
|
-
"1day": "last1day",
|
|
91
|
-
"3days": "last3days",
|
|
92
|
-
"1week": "last1week",
|
|
93
|
-
"2weeks": "last2weeks",
|
|
94
|
-
"1month": "last1month",
|
|
95
|
-
"3months": "last3months",
|
|
96
|
-
"6months": "last6months",
|
|
97
|
-
"1year": "last1year",
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# ── Result dataclass ──────────────────────────────────────────────────────
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
@dataclass(slots=True)
|
|
105
|
-
class SearchResult:
|
|
106
|
-
"""A single file/folder search result with optional metadata."""
|
|
107
|
-
|
|
108
|
-
path: str
|
|
109
|
-
name: str
|
|
110
|
-
is_dir: bool = False
|
|
111
|
-
size: int = -1
|
|
112
|
-
date_modified: str = ""
|
|
113
|
-
date_created: str = ""
|
|
114
|
-
extension: str = ""
|
|
115
|
-
|
|
116
|
-
def to_dict(self) -> dict:
|
|
117
|
-
"""Serialize to a dictionary, omitting empty/unknown fields."""
|
|
118
|
-
d: dict = {
|
|
119
|
-
"path": self.path,
|
|
120
|
-
"name": self.name,
|
|
121
|
-
"type": "folder" if self.is_dir else "file",
|
|
122
|
-
}
|
|
123
|
-
if not self.is_dir and self.size >= 0:
|
|
124
|
-
d["size"] = self.size
|
|
125
|
-
d["size_human"] = human_size(self.size)
|
|
126
|
-
if self.extension:
|
|
127
|
-
d["extension"] = self.extension
|
|
128
|
-
if self.date_modified:
|
|
129
|
-
d["date_modified"] = self.date_modified
|
|
130
|
-
if self.date_created:
|
|
131
|
-
d["date_created"] = self.date_created
|
|
132
|
-
return d
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
# ── Backend ───────────────────────────────────────────────────────────────
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
class EverythingBackend:
|
|
139
|
-
"""Async backend for executing searches via es.exe subprocess calls."""
|
|
140
|
-
|
|
141
|
-
def __init__(self, config: EverythingConfig) -> None:
|
|
142
|
-
self.config = config
|
|
143
|
-
|
|
144
|
-
# ── Primary search ────────────────────────────────────────────────
|
|
145
|
-
|
|
146
|
-
async def search(
|
|
147
|
-
self,
|
|
148
|
-
query: str,
|
|
149
|
-
max_results: int = 100,
|
|
150
|
-
sort: str = "name",
|
|
151
|
-
match_case: bool = False,
|
|
152
|
-
match_whole_word: bool = False,
|
|
153
|
-
match_regex: bool = False,
|
|
154
|
-
match_path: bool = False,
|
|
155
|
-
offset: int = 0,
|
|
156
|
-
) -> list[SearchResult]:
|
|
157
|
-
"""Execute a search query and return enriched results.
|
|
158
|
-
|
|
159
|
-
Returns a list of :class:`SearchResult` objects with metadata
|
|
160
|
-
populated via ``os.stat()``.
|
|
161
|
-
"""
|
|
162
|
-
cmd = self._base_cmd()
|
|
163
|
-
|
|
164
|
-
# Result count & offset
|
|
165
|
-
cmd.extend(["-n", str(min(max_results, self.config.max_results_cap))])
|
|
166
|
-
if offset > 0:
|
|
167
|
-
cmd.extend(["-o", str(offset)])
|
|
168
|
-
|
|
169
|
-
# Sort
|
|
170
|
-
sort_value = SORT_MAP.get(sort, sort)
|
|
171
|
-
cmd.extend(["-sort", sort_value])
|
|
172
|
-
|
|
173
|
-
# Match modifiers
|
|
174
|
-
if match_case:
|
|
175
|
-
cmd.append("-case")
|
|
176
|
-
if match_whole_word:
|
|
177
|
-
cmd.append("-w")
|
|
178
|
-
if match_regex:
|
|
179
|
-
cmd.append("-r")
|
|
180
|
-
if match_path:
|
|
181
|
-
cmd.append("-p")
|
|
182
|
-
|
|
183
|
-
# NOTE: We intentionally omit -size / -dm / -dc. Keeping es.exe
|
|
184
|
-
# output as plain one-path-per-line makes parsing trivial and
|
|
185
|
-
# version-independent. Metadata comes from os.stat() below.
|
|
186
|
-
cmd.append(query)
|
|
187
|
-
|
|
188
|
-
stdout, stderr, rc = await self._run(cmd)
|
|
189
|
-
|
|
190
|
-
if rc != 0:
|
|
191
|
-
msg = stderr.strip() or stdout.strip() or f"es.exe exited with code {rc}"
|
|
192
|
-
raise RuntimeError(f"Everything search failed: {msg}")
|
|
193
|
-
|
|
194
|
-
# Parse/stat can be expensive for large result sets; keep event loop responsive.
|
|
195
|
-
return await asyncio.to_thread(_parse_paths_and_stat, stdout)
|
|
196
|
-
|
|
197
|
-
# ── Aggregate queries ─────────────────────────────────────────────
|
|
198
|
-
|
|
199
|
-
async def count(self, query: str) -> int:
|
|
200
|
-
"""Return the number of results for *query* without listing them."""
|
|
201
|
-
cmd = self._base_cmd()
|
|
202
|
-
# Important: do not combine with "-n 0" because es.exe then reports 0.
|
|
203
|
-
cmd.extend(["-get-result-count", query])
|
|
204
|
-
stdout, stderr, rc = await self._run(cmd)
|
|
205
|
-
|
|
206
|
-
if rc != 0:
|
|
207
|
-
raise RuntimeError(f"Count failed: {stderr.strip() or stdout.strip()}")
|
|
208
|
-
|
|
209
|
-
try:
|
|
210
|
-
return int(stdout.strip())
|
|
211
|
-
except ValueError:
|
|
212
|
-
return -1
|
|
213
|
-
|
|
214
|
-
async def get_total_size(self, query: str) -> int:
|
|
215
|
-
"""Return the total size in bytes of all files matching *query*."""
|
|
216
|
-
cmd = self._base_cmd()
|
|
217
|
-
# Important: do not combine with "-n 0" because es.exe then reports 0.
|
|
218
|
-
cmd.extend(["-get-total-size", query])
|
|
219
|
-
stdout, stderr, rc = await self._run(cmd)
|
|
220
|
-
|
|
221
|
-
if rc != 0:
|
|
222
|
-
raise RuntimeError(f"Total size failed: {stderr.strip() or stdout.strip()}")
|
|
223
|
-
|
|
224
|
-
try:
|
|
225
|
-
return int(stdout.strip())
|
|
226
|
-
except ValueError:
|
|
227
|
-
return -1
|
|
228
|
-
|
|
229
|
-
# ── Health check ──────────────────────────────────────────────────
|
|
230
|
-
|
|
231
|
-
async def health_check(self) -> dict:
|
|
232
|
-
"""Check if Everything is accessible and return status info."""
|
|
233
|
-
if not self.config.is_valid:
|
|
234
|
-
return {
|
|
235
|
-
"status": "error",
|
|
236
|
-
"errors": self.config.errors,
|
|
237
|
-
"es_path": self.config.es_path or "not found",
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
try:
|
|
241
|
-
cmd = self._base_cmd()
|
|
242
|
-
cmd.append("-get-everything-version")
|
|
243
|
-
stdout, _, rc = await self._run(cmd)
|
|
244
|
-
if rc == 0 and stdout.strip():
|
|
245
|
-
return {
|
|
246
|
-
"status": "ok",
|
|
247
|
-
"everything_version": stdout.strip(),
|
|
248
|
-
"es_path": self.config.es_path,
|
|
249
|
-
"instance": self.config.instance or "default",
|
|
250
|
-
}
|
|
251
|
-
return {
|
|
252
|
-
"status": "error",
|
|
253
|
-
"message": "Unexpected response from Everything",
|
|
254
|
-
"es_path": self.config.es_path,
|
|
255
|
-
}
|
|
256
|
-
except Exception as exc:
|
|
257
|
-
return {"status": "error", "message": str(exc)}
|
|
258
|
-
|
|
259
|
-
# ── Internals ─────────────────────────────────────────────────────
|
|
260
|
-
|
|
261
|
-
def _base_cmd(self) -> list[str]:
|
|
262
|
-
"""Build the base es.exe command with optional instance flag."""
|
|
263
|
-
cmd = [self.config.es_path]
|
|
264
|
-
if self.config.instance:
|
|
265
|
-
cmd.extend(["-instance", self.config.instance])
|
|
266
|
-
return cmd
|
|
267
|
-
|
|
268
|
-
async def _run(self, cmd: list[str]) -> tuple[str, str, int]:
|
|
269
|
-
"""Run es.exe asynchronously. Returns ``(stdout, stderr, returncode)``."""
|
|
270
|
-
try:
|
|
271
|
-
kwargs: dict = dict(
|
|
272
|
-
stdout=asyncio.subprocess.PIPE,
|
|
273
|
-
stderr=asyncio.subprocess.PIPE,
|
|
274
|
-
)
|
|
275
|
-
# CREATE_NO_WINDOW only exists on Windows
|
|
276
|
-
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
277
|
-
if create_no_window:
|
|
278
|
-
kwargs["creationflags"] = create_no_window
|
|
279
|
-
|
|
280
|
-
process = await asyncio.create_subprocess_exec(*cmd, **kwargs)
|
|
281
|
-
|
|
282
|
-
stdout_raw, stderr_raw = await asyncio.wait_for(
|
|
283
|
-
process.communicate(),
|
|
284
|
-
timeout=self.config.timeout,
|
|
285
|
-
)
|
|
286
|
-
|
|
287
|
-
return (
|
|
288
|
-
_decode_output(stdout_raw),
|
|
289
|
-
_decode_output(stderr_raw),
|
|
290
|
-
process.returncode or 0,
|
|
291
|
-
)
|
|
292
|
-
|
|
293
|
-
except asyncio.TimeoutError as exc:
|
|
294
|
-
with contextlib.suppress(Exception):
|
|
295
|
-
process.kill() # type: ignore[possibly-undefined]
|
|
296
|
-
raise RuntimeError(
|
|
297
|
-
f"Search timed out after {self.config.timeout}s. "
|
|
298
|
-
"Try a more specific query or increase timeout."
|
|
299
|
-
) from exc
|
|
300
|
-
except FileNotFoundError as exc:
|
|
301
|
-
raise RuntimeError(
|
|
302
|
-
f"es.exe not found at: {self.config.es_path}. "
|
|
303
|
-
"Verify Everything is installed."
|
|
304
|
-
) from exc
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
# ── Parsing & enrichment ──────────────────────────────────────────────────
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def _parse_paths_and_stat(stdout: str) -> list[SearchResult]:
|
|
311
|
-
"""Parse es.exe plain output (one path per line) and enrich via os.stat().
|
|
312
|
-
|
|
313
|
-
Robustly handles:
|
|
314
|
-
- Blank lines (skipped)
|
|
315
|
-
- Paths with spaces or unicode characters
|
|
316
|
-
- Inaccessible paths (returns result with size=-1)
|
|
317
|
-
"""
|
|
318
|
-
results: list[SearchResult] = []
|
|
319
|
-
|
|
320
|
-
for raw_line in stdout.splitlines():
|
|
321
|
-
# Preserve significant whitespace in file names; only trim line endings.
|
|
322
|
-
filepath = raw_line.rstrip("\r\n")
|
|
323
|
-
if not filepath.strip():
|
|
324
|
-
continue
|
|
325
|
-
|
|
326
|
-
# Validate that this looks like a real path (drive letter or UNC)
|
|
327
|
-
if not _looks_like_path(filepath):
|
|
328
|
-
logger.debug("Skipping non-path line: %r", filepath[:120])
|
|
329
|
-
continue
|
|
330
|
-
|
|
331
|
-
result = _stat_to_result(filepath)
|
|
332
|
-
if result is not None:
|
|
333
|
-
results.append(result)
|
|
334
|
-
|
|
335
|
-
return results
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def _looks_like_path(s: str) -> bool:
|
|
339
|
-
"""Quick heuristic: does *s* look like a Windows or UNC path?"""
|
|
340
|
-
# Drive letter: C:\...
|
|
341
|
-
if len(s) >= 3 and s[0].isalpha() and s[1] == ":" and s[2] in ("/", "\\"):
|
|
342
|
-
return True
|
|
343
|
-
# UNC: \\server\share
|
|
344
|
-
if s.startswith("\\\\"):
|
|
345
|
-
return True
|
|
346
|
-
# Unix-style (for testing or WSL)
|
|
347
|
-
return s.startswith("/")
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def _stat_to_result(filepath: str) -> SearchResult | None:
|
|
351
|
-
"""Create a :class:`SearchResult` from a filepath, enriching with os.stat()."""
|
|
352
|
-
try:
|
|
353
|
-
p = Path(filepath)
|
|
354
|
-
name = p.name or filepath # root drives have empty name
|
|
355
|
-
is_dir = p.is_dir()
|
|
356
|
-
ext = p.suffix.lstrip(".").lower() if not is_dir else ""
|
|
357
|
-
|
|
358
|
-
size = -1
|
|
359
|
-
dm = ""
|
|
360
|
-
dc = ""
|
|
361
|
-
try:
|
|
362
|
-
stat = p.stat()
|
|
363
|
-
size = stat.st_size if not is_dir else -1
|
|
364
|
-
dm = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
365
|
-
dc = datetime.fromtimestamp(stat.st_ctime).strftime("%Y-%m-%d %H:%M:%S")
|
|
366
|
-
except OSError:
|
|
367
|
-
pass # Inaccessible
|
|
368
|
-
|
|
369
|
-
return SearchResult(
|
|
370
|
-
path=str(p),
|
|
371
|
-
name=name,
|
|
372
|
-
is_dir=is_dir,
|
|
373
|
-
size=size,
|
|
374
|
-
date_modified=dm,
|
|
375
|
-
date_created=dc,
|
|
376
|
-
extension=ext,
|
|
377
|
-
)
|
|
378
|
-
except Exception as exc:
|
|
379
|
-
logger.debug("Failed to stat '%s': %s", filepath, exc)
|
|
380
|
-
# Return a bare result so we at least report the path
|
|
381
|
-
return SearchResult(path=filepath, name=Path(filepath).name or filepath)
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
# ── Query builders ────────────────────────────────────────────────────────
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
def build_type_query(file_type: str, additional_query: str = "", path_filter: str = "") -> str:
|
|
388
|
-
"""Build a search query for a specific file type category.
|
|
389
|
-
|
|
390
|
-
Raises :class:`ValueError` if *file_type* is not a known category.
|
|
391
|
-
"""
|
|
392
|
-
key = file_type.lower().strip()
|
|
393
|
-
if key not in FILE_TYPES:
|
|
394
|
-
available = ", ".join(sorted(FILE_TYPES.keys()))
|
|
395
|
-
raise ValueError(f"Unknown file type '{file_type}'. Available: {available}")
|
|
396
|
-
|
|
397
|
-
parts = [FILE_TYPES[key]]
|
|
398
|
-
if path_filter:
|
|
399
|
-
parts.append(f'path:"{path_filter}"')
|
|
400
|
-
if additional_query:
|
|
401
|
-
parts.append(additional_query)
|
|
402
|
-
return " ".join(parts)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def build_recent_query(
|
|
406
|
-
period: str = "1hour",
|
|
407
|
-
path_filter: str = "",
|
|
408
|
-
extensions: str = "",
|
|
409
|
-
) -> str:
|
|
410
|
-
"""Build a search query for recently modified files."""
|
|
411
|
-
time_value = TIME_PERIODS.get(period, period)
|
|
412
|
-
parts = [f"dm:{time_value}"]
|
|
413
|
-
|
|
414
|
-
if path_filter:
|
|
415
|
-
parts.append(f'path:"{path_filter}"')
|
|
416
|
-
if extensions:
|
|
417
|
-
# Normalize "py,js" or ".py,.js" or "py;js" → "py;js"
|
|
418
|
-
exts = extensions.replace(".", "").replace(",", ";").replace(" ", ";")
|
|
419
|
-
exts = ";".join(e for e in exts.split(";") if e) # remove empties
|
|
420
|
-
if exts:
|
|
421
|
-
parts.append(f"ext:{exts}")
|
|
422
|
-
|
|
423
|
-
return " ".join(parts)
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
# ── Utility functions ─────────────────────────────────────────────────────
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
def human_size(size: int) -> str:
|
|
430
|
-
"""Convert bytes to a human-readable size string (e.g. ``1.5 MB``)."""
|
|
431
|
-
if size < 0:
|
|
432
|
-
return "unknown"
|
|
433
|
-
value = float(size)
|
|
434
|
-
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
435
|
-
if value < 1024.0:
|
|
436
|
-
if unit == "B":
|
|
437
|
-
return f"{int(value)} {unit}"
|
|
438
|
-
return f"{value:.1f} {unit}"
|
|
439
|
-
value /= 1024.0
|
|
440
|
-
return f"{value:.1f} PB"
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
def _decode_output(data: bytes) -> str:
|
|
444
|
-
"""Decode subprocess output, trying UTF-8 first then system encoding."""
|
|
445
|
-
if data.startswith(b"\xef\xbb\xbf"):
|
|
446
|
-
return data[3:].decode("utf-8", errors="replace")
|
|
447
|
-
try:
|
|
448
|
-
return data.decode("utf-8")
|
|
449
|
-
except UnicodeDecodeError:
|
|
450
|
-
pass
|
|
451
|
-
encoding = locale.getpreferredencoding(False)
|
|
452
|
-
try:
|
|
453
|
-
return data.decode(encoding)
|
|
454
|
-
except (UnicodeDecodeError, LookupError):
|
|
455
|
-
return data.decode("utf-8", errors="replace")
|
|
1
|
+
"""
|
|
2
|
+
Backend for communicating with voidtools Everything via es.exe.
|
|
3
|
+
|
|
4
|
+
Handles query execution, result parsing, and metadata enrichment.
|
|
5
|
+
|
|
6
|
+
Design decision: es.exe is invoked *without* ``-size -dm -dc`` flags.
|
|
7
|
+
This produces clean one-path-per-line output that is trivially parseable
|
|
8
|
+
regardless of es.exe version, locale, or output encoding. Metadata is
|
|
9
|
+
then enriched via ``os.stat()`` - fast, reliable, and cross-version.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import locale
|
|
17
|
+
import logging
|
|
18
|
+
import subprocess
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from everything_mcp.config import EverythingConfig
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"EverythingBackend",
|
|
27
|
+
"SearchResult",
|
|
28
|
+
"build_type_query",
|
|
29
|
+
"build_recent_query",
|
|
30
|
+
"human_size",
|
|
31
|
+
"FILE_TYPES",
|
|
32
|
+
"SORT_MAP",
|
|
33
|
+
"TIME_PERIODS",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("everything_mcp")
|
|
37
|
+
|
|
38
|
+
# ── Constants ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
# Friendly sort names → es.exe -sort values
|
|
41
|
+
SORT_MAP: dict[str, str] = {
|
|
42
|
+
"name": "name",
|
|
43
|
+
"name-desc": "name-descending",
|
|
44
|
+
"path": "path",
|
|
45
|
+
"path-desc": "path-descending",
|
|
46
|
+
"size": "size",
|
|
47
|
+
"size-asc": "size",
|
|
48
|
+
"size-desc": "size-descending",
|
|
49
|
+
"date-modified": "date-modified",
|
|
50
|
+
"date-modified-asc": "date-modified",
|
|
51
|
+
"date-modified-desc": "date-modified-descending",
|
|
52
|
+
"date-created": "date-created",
|
|
53
|
+
"date-created-asc": "date-created",
|
|
54
|
+
"date-created-desc": "date-created-descending",
|
|
55
|
+
"extension": "extension",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# File type categories → Everything ext: queries
|
|
59
|
+
FILE_TYPES: dict[str, str] = {
|
|
60
|
+
"audio": "ext:mp3;wav;flac;aac;ogg;wma;m4a;opus;aiff;alac",
|
|
61
|
+
"video": "ext:mp4;avi;mkv;mov;wmv;flv;webm;m4v;mpeg;mpg;3gp;ts",
|
|
62
|
+
"image": "ext:jpg;jpeg;png;gif;bmp;svg;webp;tiff;tif;ico;raw;heic;heif;avif;psd",
|
|
63
|
+
"document": "ext:pdf;doc;docx;xls;xlsx;ppt;pptx;odt;ods;odp;rtf;txt;md;epub;pages;numbers;key",
|
|
64
|
+
"code": (
|
|
65
|
+
"ext:py;js;ts;jsx;tsx;c;cpp;h;hpp;cs;java;go;rs;rb;php;swift;kt;scala;r;"
|
|
66
|
+
"lua;sh;bash;ps1;bat;cmd;sql;html;css;scss;sass;less;vue;svelte;dart;zig;"
|
|
67
|
+
"nim;hx;ex;exs;erl;hs;ml;fs;clj;lisp;asm;toml;yaml;yml;json;xml;ini;cfg;"
|
|
68
|
+
"conf;env;dockerfile;makefile;cmake;gradle;sbt;proto;graphql;tf;hcl"
|
|
69
|
+
),
|
|
70
|
+
"archive": "ext:zip;rar;7z;tar;gz;bz2;xz;tgz;zst;lz4;cab;iso;dmg",
|
|
71
|
+
"executable": "ext:exe;msi;dll;sys;com;scr;appx;msix",
|
|
72
|
+
"font": "ext:ttf;otf;woff;woff2;eot;fon",
|
|
73
|
+
"3d": "ext:obj;fbx;stl;blend;dae;3ds;gltf;glb;usd;usda;usdz;step;iges",
|
|
74
|
+
"data": "ext:csv;tsv;json;jsonl;ndjson;xml;sqlite;db;mdb;accdb;parquet;arrow;avro;hdf5;feather",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Time period shortcuts → Everything dm: values
|
|
78
|
+
TIME_PERIODS: dict[str, str] = {
|
|
79
|
+
"1min": "last1min",
|
|
80
|
+
"5min": "last5mins",
|
|
81
|
+
"10min": "last10mins",
|
|
82
|
+
"15min": "last15mins",
|
|
83
|
+
"30min": "last30mins",
|
|
84
|
+
"1hour": "last1hour",
|
|
85
|
+
"2hours": "last2hours",
|
|
86
|
+
"6hours": "last6hours",
|
|
87
|
+
"12hours": "last12hours",
|
|
88
|
+
"today": "today",
|
|
89
|
+
"yesterday": "yesterday",
|
|
90
|
+
"1day": "last1day",
|
|
91
|
+
"3days": "last3days",
|
|
92
|
+
"1week": "last1week",
|
|
93
|
+
"2weeks": "last2weeks",
|
|
94
|
+
"1month": "last1month",
|
|
95
|
+
"3months": "last3months",
|
|
96
|
+
"6months": "last6months",
|
|
97
|
+
"1year": "last1year",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── Result dataclass ──────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(slots=True)
|
|
105
|
+
class SearchResult:
|
|
106
|
+
"""A single file/folder search result with optional metadata."""
|
|
107
|
+
|
|
108
|
+
path: str
|
|
109
|
+
name: str
|
|
110
|
+
is_dir: bool = False
|
|
111
|
+
size: int = -1
|
|
112
|
+
date_modified: str = ""
|
|
113
|
+
date_created: str = ""
|
|
114
|
+
extension: str = ""
|
|
115
|
+
|
|
116
|
+
def to_dict(self) -> dict:
|
|
117
|
+
"""Serialize to a dictionary, omitting empty/unknown fields."""
|
|
118
|
+
d: dict = {
|
|
119
|
+
"path": self.path,
|
|
120
|
+
"name": self.name,
|
|
121
|
+
"type": "folder" if self.is_dir else "file",
|
|
122
|
+
}
|
|
123
|
+
if not self.is_dir and self.size >= 0:
|
|
124
|
+
d["size"] = self.size
|
|
125
|
+
d["size_human"] = human_size(self.size)
|
|
126
|
+
if self.extension:
|
|
127
|
+
d["extension"] = self.extension
|
|
128
|
+
if self.date_modified:
|
|
129
|
+
d["date_modified"] = self.date_modified
|
|
130
|
+
if self.date_created:
|
|
131
|
+
d["date_created"] = self.date_created
|
|
132
|
+
return d
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── Backend ───────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class EverythingBackend:
|
|
139
|
+
"""Async backend for executing searches via es.exe subprocess calls."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, config: EverythingConfig) -> None:
|
|
142
|
+
self.config = config
|
|
143
|
+
|
|
144
|
+
# ── Primary search ────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
async def search(
|
|
147
|
+
self,
|
|
148
|
+
query: str,
|
|
149
|
+
max_results: int = 100,
|
|
150
|
+
sort: str = "name",
|
|
151
|
+
match_case: bool = False,
|
|
152
|
+
match_whole_word: bool = False,
|
|
153
|
+
match_regex: bool = False,
|
|
154
|
+
match_path: bool = False,
|
|
155
|
+
offset: int = 0,
|
|
156
|
+
) -> list[SearchResult]:
|
|
157
|
+
"""Execute a search query and return enriched results.
|
|
158
|
+
|
|
159
|
+
Returns a list of :class:`SearchResult` objects with metadata
|
|
160
|
+
populated via ``os.stat()``.
|
|
161
|
+
"""
|
|
162
|
+
cmd = self._base_cmd()
|
|
163
|
+
|
|
164
|
+
# Result count & offset
|
|
165
|
+
cmd.extend(["-n", str(min(max_results, self.config.max_results_cap))])
|
|
166
|
+
if offset > 0:
|
|
167
|
+
cmd.extend(["-o", str(offset)])
|
|
168
|
+
|
|
169
|
+
# Sort
|
|
170
|
+
sort_value = SORT_MAP.get(sort, sort)
|
|
171
|
+
cmd.extend(["-sort", sort_value])
|
|
172
|
+
|
|
173
|
+
# Match modifiers
|
|
174
|
+
if match_case:
|
|
175
|
+
cmd.append("-case")
|
|
176
|
+
if match_whole_word:
|
|
177
|
+
cmd.append("-w")
|
|
178
|
+
if match_regex:
|
|
179
|
+
cmd.append("-r")
|
|
180
|
+
if match_path:
|
|
181
|
+
cmd.append("-p")
|
|
182
|
+
|
|
183
|
+
# NOTE: We intentionally omit -size / -dm / -dc. Keeping es.exe
|
|
184
|
+
# output as plain one-path-per-line makes parsing trivial and
|
|
185
|
+
# version-independent. Metadata comes from os.stat() below.
|
|
186
|
+
cmd.append(query)
|
|
187
|
+
|
|
188
|
+
stdout, stderr, rc = await self._run(cmd)
|
|
189
|
+
|
|
190
|
+
if rc != 0:
|
|
191
|
+
msg = stderr.strip() or stdout.strip() or f"es.exe exited with code {rc}"
|
|
192
|
+
raise RuntimeError(f"Everything search failed: {msg}")
|
|
193
|
+
|
|
194
|
+
# Parse/stat can be expensive for large result sets; keep event loop responsive.
|
|
195
|
+
return await asyncio.to_thread(_parse_paths_and_stat, stdout)
|
|
196
|
+
|
|
197
|
+
# ── Aggregate queries ─────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
async def count(self, query: str) -> int:
|
|
200
|
+
"""Return the number of results for *query* without listing them."""
|
|
201
|
+
cmd = self._base_cmd()
|
|
202
|
+
# Important: do not combine with "-n 0" because es.exe then reports 0.
|
|
203
|
+
cmd.extend(["-get-result-count", query])
|
|
204
|
+
stdout, stderr, rc = await self._run(cmd)
|
|
205
|
+
|
|
206
|
+
if rc != 0:
|
|
207
|
+
raise RuntimeError(f"Count failed: {stderr.strip() or stdout.strip()}")
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
return int(stdout.strip())
|
|
211
|
+
except ValueError:
|
|
212
|
+
return -1
|
|
213
|
+
|
|
214
|
+
async def get_total_size(self, query: str) -> int:
|
|
215
|
+
"""Return the total size in bytes of all files matching *query*."""
|
|
216
|
+
cmd = self._base_cmd()
|
|
217
|
+
# Important: do not combine with "-n 0" because es.exe then reports 0.
|
|
218
|
+
cmd.extend(["-get-total-size", query])
|
|
219
|
+
stdout, stderr, rc = await self._run(cmd)
|
|
220
|
+
|
|
221
|
+
if rc != 0:
|
|
222
|
+
raise RuntimeError(f"Total size failed: {stderr.strip() or stdout.strip()}")
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
return int(stdout.strip())
|
|
226
|
+
except ValueError:
|
|
227
|
+
return -1
|
|
228
|
+
|
|
229
|
+
# ── Health check ──────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
async def health_check(self) -> dict:
|
|
232
|
+
"""Check if Everything is accessible and return status info."""
|
|
233
|
+
if not self.config.is_valid:
|
|
234
|
+
return {
|
|
235
|
+
"status": "error",
|
|
236
|
+
"errors": self.config.errors,
|
|
237
|
+
"es_path": self.config.es_path or "not found",
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
cmd = self._base_cmd()
|
|
242
|
+
cmd.append("-get-everything-version")
|
|
243
|
+
stdout, _, rc = await self._run(cmd)
|
|
244
|
+
if rc == 0 and stdout.strip():
|
|
245
|
+
return {
|
|
246
|
+
"status": "ok",
|
|
247
|
+
"everything_version": stdout.strip(),
|
|
248
|
+
"es_path": self.config.es_path,
|
|
249
|
+
"instance": self.config.instance or "default",
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
"status": "error",
|
|
253
|
+
"message": "Unexpected response from Everything",
|
|
254
|
+
"es_path": self.config.es_path,
|
|
255
|
+
}
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
return {"status": "error", "message": str(exc)}
|
|
258
|
+
|
|
259
|
+
# ── Internals ─────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
def _base_cmd(self) -> list[str]:
|
|
262
|
+
"""Build the base es.exe command with optional instance flag."""
|
|
263
|
+
cmd = [self.config.es_path]
|
|
264
|
+
if self.config.instance:
|
|
265
|
+
cmd.extend(["-instance", self.config.instance])
|
|
266
|
+
return cmd
|
|
267
|
+
|
|
268
|
+
async def _run(self, cmd: list[str]) -> tuple[str, str, int]:
|
|
269
|
+
"""Run es.exe asynchronously. Returns ``(stdout, stderr, returncode)``."""
|
|
270
|
+
try:
|
|
271
|
+
kwargs: dict = dict(
|
|
272
|
+
stdout=asyncio.subprocess.PIPE,
|
|
273
|
+
stderr=asyncio.subprocess.PIPE,
|
|
274
|
+
)
|
|
275
|
+
# CREATE_NO_WINDOW only exists on Windows
|
|
276
|
+
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
277
|
+
if create_no_window:
|
|
278
|
+
kwargs["creationflags"] = create_no_window
|
|
279
|
+
|
|
280
|
+
process = await asyncio.create_subprocess_exec(*cmd, **kwargs)
|
|
281
|
+
|
|
282
|
+
stdout_raw, stderr_raw = await asyncio.wait_for(
|
|
283
|
+
process.communicate(),
|
|
284
|
+
timeout=self.config.timeout,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
_decode_output(stdout_raw),
|
|
289
|
+
_decode_output(stderr_raw),
|
|
290
|
+
process.returncode or 0,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
except asyncio.TimeoutError as exc:
|
|
294
|
+
with contextlib.suppress(Exception):
|
|
295
|
+
process.kill() # type: ignore[possibly-undefined]
|
|
296
|
+
raise RuntimeError(
|
|
297
|
+
f"Search timed out after {self.config.timeout}s. "
|
|
298
|
+
"Try a more specific query or increase timeout."
|
|
299
|
+
) from exc
|
|
300
|
+
except FileNotFoundError as exc:
|
|
301
|
+
raise RuntimeError(
|
|
302
|
+
f"es.exe not found at: {self.config.es_path}. "
|
|
303
|
+
"Verify Everything is installed."
|
|
304
|
+
) from exc
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ── Parsing & enrichment ──────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _parse_paths_and_stat(stdout: str) -> list[SearchResult]:
|
|
311
|
+
"""Parse es.exe plain output (one path per line) and enrich via os.stat().
|
|
312
|
+
|
|
313
|
+
Robustly handles:
|
|
314
|
+
- Blank lines (skipped)
|
|
315
|
+
- Paths with spaces or unicode characters
|
|
316
|
+
- Inaccessible paths (returns result with size=-1)
|
|
317
|
+
"""
|
|
318
|
+
results: list[SearchResult] = []
|
|
319
|
+
|
|
320
|
+
for raw_line in stdout.splitlines():
|
|
321
|
+
# Preserve significant whitespace in file names; only trim line endings.
|
|
322
|
+
filepath = raw_line.rstrip("\r\n")
|
|
323
|
+
if not filepath.strip():
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
# Validate that this looks like a real path (drive letter or UNC)
|
|
327
|
+
if not _looks_like_path(filepath):
|
|
328
|
+
logger.debug("Skipping non-path line: %r", filepath[:120])
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
result = _stat_to_result(filepath)
|
|
332
|
+
if result is not None:
|
|
333
|
+
results.append(result)
|
|
334
|
+
|
|
335
|
+
return results
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _looks_like_path(s: str) -> bool:
|
|
339
|
+
"""Quick heuristic: does *s* look like a Windows or UNC path?"""
|
|
340
|
+
# Drive letter: C:\...
|
|
341
|
+
if len(s) >= 3 and s[0].isalpha() and s[1] == ":" and s[2] in ("/", "\\"):
|
|
342
|
+
return True
|
|
343
|
+
# UNC: \\server\share
|
|
344
|
+
if s.startswith("\\\\"):
|
|
345
|
+
return True
|
|
346
|
+
# Unix-style (for testing or WSL)
|
|
347
|
+
return s.startswith("/")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _stat_to_result(filepath: str) -> SearchResult | None:
|
|
351
|
+
"""Create a :class:`SearchResult` from a filepath, enriching with os.stat()."""
|
|
352
|
+
try:
|
|
353
|
+
p = Path(filepath)
|
|
354
|
+
name = p.name or filepath # root drives have empty name
|
|
355
|
+
is_dir = p.is_dir()
|
|
356
|
+
ext = p.suffix.lstrip(".").lower() if not is_dir else ""
|
|
357
|
+
|
|
358
|
+
size = -1
|
|
359
|
+
dm = ""
|
|
360
|
+
dc = ""
|
|
361
|
+
try:
|
|
362
|
+
stat = p.stat()
|
|
363
|
+
size = stat.st_size if not is_dir else -1
|
|
364
|
+
dm = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
365
|
+
dc = datetime.fromtimestamp(stat.st_ctime).strftime("%Y-%m-%d %H:%M:%S")
|
|
366
|
+
except OSError:
|
|
367
|
+
pass # Inaccessible - still return the path
|
|
368
|
+
|
|
369
|
+
return SearchResult(
|
|
370
|
+
path=str(p),
|
|
371
|
+
name=name,
|
|
372
|
+
is_dir=is_dir,
|
|
373
|
+
size=size,
|
|
374
|
+
date_modified=dm,
|
|
375
|
+
date_created=dc,
|
|
376
|
+
extension=ext,
|
|
377
|
+
)
|
|
378
|
+
except Exception as exc:
|
|
379
|
+
logger.debug("Failed to stat '%s': %s", filepath, exc)
|
|
380
|
+
# Return a bare result so we at least report the path
|
|
381
|
+
return SearchResult(path=filepath, name=Path(filepath).name or filepath)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ── Query builders ────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def build_type_query(file_type: str, additional_query: str = "", path_filter: str = "") -> str:
|
|
388
|
+
"""Build a search query for a specific file type category.
|
|
389
|
+
|
|
390
|
+
Raises :class:`ValueError` if *file_type* is not a known category.
|
|
391
|
+
"""
|
|
392
|
+
key = file_type.lower().strip()
|
|
393
|
+
if key not in FILE_TYPES:
|
|
394
|
+
available = ", ".join(sorted(FILE_TYPES.keys()))
|
|
395
|
+
raise ValueError(f"Unknown file type '{file_type}'. Available: {available}")
|
|
396
|
+
|
|
397
|
+
parts = [FILE_TYPES[key]]
|
|
398
|
+
if path_filter:
|
|
399
|
+
parts.append(f'path:"{path_filter}"')
|
|
400
|
+
if additional_query:
|
|
401
|
+
parts.append(additional_query)
|
|
402
|
+
return " ".join(parts)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def build_recent_query(
|
|
406
|
+
period: str = "1hour",
|
|
407
|
+
path_filter: str = "",
|
|
408
|
+
extensions: str = "",
|
|
409
|
+
) -> str:
|
|
410
|
+
"""Build a search query for recently modified files."""
|
|
411
|
+
time_value = TIME_PERIODS.get(period, period)
|
|
412
|
+
parts = [f"dm:{time_value}"]
|
|
413
|
+
|
|
414
|
+
if path_filter:
|
|
415
|
+
parts.append(f'path:"{path_filter}"')
|
|
416
|
+
if extensions:
|
|
417
|
+
# Normalize "py,js" or ".py,.js" or "py;js" → "py;js"
|
|
418
|
+
exts = extensions.replace(".", "").replace(",", ";").replace(" ", ";")
|
|
419
|
+
exts = ";".join(e for e in exts.split(";") if e) # remove empties
|
|
420
|
+
if exts:
|
|
421
|
+
parts.append(f"ext:{exts}")
|
|
422
|
+
|
|
423
|
+
return " ".join(parts)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ── Utility functions ─────────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def human_size(size: int) -> str:
|
|
430
|
+
"""Convert bytes to a human-readable size string (e.g. ``1.5 MB``)."""
|
|
431
|
+
if size < 0:
|
|
432
|
+
return "unknown"
|
|
433
|
+
value = float(size)
|
|
434
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
435
|
+
if value < 1024.0:
|
|
436
|
+
if unit == "B":
|
|
437
|
+
return f"{int(value)} {unit}"
|
|
438
|
+
return f"{value:.1f} {unit}"
|
|
439
|
+
value /= 1024.0
|
|
440
|
+
return f"{value:.1f} PB"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _decode_output(data: bytes) -> str:
|
|
444
|
+
"""Decode subprocess output, trying UTF-8 first then system encoding."""
|
|
445
|
+
if data.startswith(b"\xef\xbb\xbf"):
|
|
446
|
+
return data[3:].decode("utf-8", errors="replace")
|
|
447
|
+
try:
|
|
448
|
+
return data.decode("utf-8")
|
|
449
|
+
except UnicodeDecodeError:
|
|
450
|
+
pass
|
|
451
|
+
encoding = locale.getpreferredencoding(False)
|
|
452
|
+
try:
|
|
453
|
+
return data.decode(encoding)
|
|
454
|
+
except (UnicodeDecodeError, LookupError):
|
|
455
|
+
return data.decode("utf-8", errors="replace")
|