minimalcode 1.5.2__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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- minimalcode-1.5.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Search primitives for the grep and find tools.
|
|
2
|
+
|
|
3
|
+
The same ignore rules and regex semantics are applied by two engines: ripgrep
|
|
4
|
+
when it can be spawned, and a bounded Python fallback otherwise. This module
|
|
5
|
+
owns process launch and output parsing so the runtime only has to choose an
|
|
6
|
+
engine and shape the result.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import re
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ..errors import ToolError
|
|
25
|
+
from ..sandbox import SandboxRunner
|
|
26
|
+
|
|
27
|
+
# Shared by the Python fallback walk and the ripgrep invocations so both
|
|
28
|
+
# implementations search the same set of files.
|
|
29
|
+
IGNORED_DIRECTORIES: tuple[str, ...] = (
|
|
30
|
+
".git",
|
|
31
|
+
".hg",
|
|
32
|
+
".svn",
|
|
33
|
+
".mypy_cache",
|
|
34
|
+
".pytest_cache",
|
|
35
|
+
".ruff_cache",
|
|
36
|
+
"__pycache__",
|
|
37
|
+
".venv",
|
|
38
|
+
"build",
|
|
39
|
+
"dist",
|
|
40
|
+
"node_modules",
|
|
41
|
+
"venv",
|
|
42
|
+
)
|
|
43
|
+
IGNORED_GLOBS: tuple[str, ...] = tuple(
|
|
44
|
+
glob for name in IGNORED_DIRECTORIES for glob in (f"!{name}/**", f"!**/{name}/**")
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Match ripgrep's --max-columns output width and keep the Python fallback from
|
|
48
|
+
# returning a single minified line that swallows the whole context budget.
|
|
49
|
+
_MAX_GREP_LINE_CHARS = 300
|
|
50
|
+
_MAX_READ_LINE_CHARS = 1_000_000
|
|
51
|
+
_SEARCH_TIMEOUT_SECONDS = 30
|
|
52
|
+
_SEARCH_MAX_OUTPUT_CHARS = 1_000_000
|
|
53
|
+
|
|
54
|
+
# The fallback runs in a child interpreter so a pathological regex cannot block
|
|
55
|
+
# the agent process. It receives one JSON document on stdin and emits one JSON
|
|
56
|
+
# document on stdout; line counting deliberately ignores fragments of a line
|
|
57
|
+
# longer than the per-line cap.
|
|
58
|
+
_PYTHON_GREP_SCRIPT = r"""
|
|
59
|
+
import json
|
|
60
|
+
import os
|
|
61
|
+
import re
|
|
62
|
+
import sys
|
|
63
|
+
|
|
64
|
+
spec = json.load(sys.stdin)
|
|
65
|
+
try:
|
|
66
|
+
regex = re.compile(
|
|
67
|
+
spec["pattern"], re.IGNORECASE if spec["ignore_case"] else 0
|
|
68
|
+
)
|
|
69
|
+
except re.error as exc:
|
|
70
|
+
print(json.dumps({"error": f"invalid regular expression: {exc}"}))
|
|
71
|
+
raise SystemExit(0)
|
|
72
|
+
|
|
73
|
+
matches = []
|
|
74
|
+
for path in spec["files"]:
|
|
75
|
+
try:
|
|
76
|
+
if os.path.getsize(path) > spec["max_bytes"]:
|
|
77
|
+
continue
|
|
78
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
79
|
+
number = 0
|
|
80
|
+
while True:
|
|
81
|
+
line = handle.readline(spec["max_line_chars"])
|
|
82
|
+
if not line:
|
|
83
|
+
break
|
|
84
|
+
if line.endswith("\r") and len(line) >= spec["max_line_chars"]:
|
|
85
|
+
# The limit may land between CR and LF; consume the LF so
|
|
86
|
+
# it cannot be counted as an extra empty line.
|
|
87
|
+
position = handle.tell()
|
|
88
|
+
following = handle.read(1)
|
|
89
|
+
if following == "\n":
|
|
90
|
+
line += following
|
|
91
|
+
else:
|
|
92
|
+
handle.seek(position)
|
|
93
|
+
number += 1
|
|
94
|
+
cut = len(line) >= spec["max_line_chars"] and not line.endswith(
|
|
95
|
+
("\n", "\r")
|
|
96
|
+
)
|
|
97
|
+
if cut:
|
|
98
|
+
# A line of exactly the limit may still be complete; look at
|
|
99
|
+
# the following character before dropping its tail.
|
|
100
|
+
position = handle.tell()
|
|
101
|
+
following = handle.read(1)
|
|
102
|
+
if not following:
|
|
103
|
+
cut = False
|
|
104
|
+
elif following == "\n":
|
|
105
|
+
line += following
|
|
106
|
+
cut = False
|
|
107
|
+
elif following == "\r":
|
|
108
|
+
line += following
|
|
109
|
+
peek = handle.read(1)
|
|
110
|
+
if peek == "\n":
|
|
111
|
+
line += peek
|
|
112
|
+
elif peek:
|
|
113
|
+
handle.seek(position + 1)
|
|
114
|
+
cut = False
|
|
115
|
+
else:
|
|
116
|
+
handle.seek(position)
|
|
117
|
+
if cut:
|
|
118
|
+
# Swallow the rest of this physical line so the next
|
|
119
|
+
# readline starts a new line and `number` stays aligned
|
|
120
|
+
# with the file instead of counting each fragment.
|
|
121
|
+
while True:
|
|
122
|
+
rest = handle.readline(spec["max_line_chars"])
|
|
123
|
+
if not rest:
|
|
124
|
+
break
|
|
125
|
+
if rest.endswith("\n"):
|
|
126
|
+
break
|
|
127
|
+
if rest.endswith("\r") and len(rest) >= spec["max_line_chars"]:
|
|
128
|
+
rest_position = handle.tell()
|
|
129
|
+
rest_following = handle.read(1)
|
|
130
|
+
if rest_following != "\n":
|
|
131
|
+
handle.seek(rest_position)
|
|
132
|
+
break
|
|
133
|
+
break
|
|
134
|
+
if rest.endswith("\r"):
|
|
135
|
+
break
|
|
136
|
+
if "\x00" in line:
|
|
137
|
+
continue
|
|
138
|
+
# Match the line content, not its terminator. `$` and `\s` would
|
|
139
|
+
# otherwise see the newline and report matches ripgrep never
|
|
140
|
+
# finds (`\s+$`, `[^0-9]$`), and a CRLF line would hide a real
|
|
141
|
+
# `c$` match.
|
|
142
|
+
text = line.rstrip("\r\n")
|
|
143
|
+
if not regex.search(text):
|
|
144
|
+
continue
|
|
145
|
+
if cut or len(text) > spec["max_columns"]:
|
|
146
|
+
text = text[: spec["max_columns"]] + " [... omitted end of long line]"
|
|
147
|
+
matches.append(f"{path}:{number}:{text}")
|
|
148
|
+
# One match past the limit proves truncation; an exact fit is a
|
|
149
|
+
# complete result, matching the ripgrep and inline engines.
|
|
150
|
+
if len(matches) > spec["limit"]:
|
|
151
|
+
print(
|
|
152
|
+
json.dumps(
|
|
153
|
+
{"matches": matches[: spec["limit"]], "truncated": True}
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
raise SystemExit(0)
|
|
157
|
+
except (OSError, UnicodeError):
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
print(json.dumps({"matches": matches, "truncated": False}))
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def smart_case_ignore(pattern: str) -> bool:
|
|
165
|
+
"""Return True when ripgrep's --smart-case would ignore case."""
|
|
166
|
+
literal: list[str] = []
|
|
167
|
+
index = 0
|
|
168
|
+
while index < len(pattern):
|
|
169
|
+
if pattern[index] == "\\" and index + 1 < len(pattern):
|
|
170
|
+
# An escaped character carries no case of its own. Skipping the
|
|
171
|
+
# pair keeps `\\D` (an escaped backslash plus a literal `D`) visible
|
|
172
|
+
# to the check, which stripping every letter escape hid.
|
|
173
|
+
index += 2
|
|
174
|
+
continue
|
|
175
|
+
literal.append(pattern[index])
|
|
176
|
+
index += 1
|
|
177
|
+
return not any(char.isupper() for char in literal)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def drain_physical_line(handle: Any, limit: int) -> None:
|
|
181
|
+
"""Consume the rest of a line that was longer than ``limit``."""
|
|
182
|
+
while True:
|
|
183
|
+
rest = handle.readline(limit)
|
|
184
|
+
if not rest:
|
|
185
|
+
return
|
|
186
|
+
if rest.endswith("\n"):
|
|
187
|
+
return
|
|
188
|
+
if rest.endswith("\r") and len(rest) >= limit:
|
|
189
|
+
# A CRLF split at the limit must consume the LF too; otherwise it
|
|
190
|
+
# becomes an empty line and shifts every later line number.
|
|
191
|
+
position = handle.tell()
|
|
192
|
+
following = handle.read(1)
|
|
193
|
+
if following != "\n":
|
|
194
|
+
handle.seek(position)
|
|
195
|
+
return
|
|
196
|
+
return
|
|
197
|
+
if rest.endswith("\r"):
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _kill_process_tree(process: subprocess.Popen[bytes]) -> None:
|
|
202
|
+
"""Kill a search child and any descendant that inherited its stdout."""
|
|
203
|
+
if os.name == "nt":
|
|
204
|
+
try:
|
|
205
|
+
subprocess.run(
|
|
206
|
+
["taskkill", "/F", "/T", "/PID", str(process.pid)],
|
|
207
|
+
stdin=subprocess.DEVNULL,
|
|
208
|
+
stdout=subprocess.DEVNULL,
|
|
209
|
+
stderr=subprocess.DEVNULL,
|
|
210
|
+
timeout=10,
|
|
211
|
+
check=False,
|
|
212
|
+
)
|
|
213
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
214
|
+
pass
|
|
215
|
+
else:
|
|
216
|
+
try:
|
|
217
|
+
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
218
|
+
except OSError:
|
|
219
|
+
pass
|
|
220
|
+
try:
|
|
221
|
+
process.kill()
|
|
222
|
+
except OSError:
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def inline_guard_available() -> bool:
|
|
227
|
+
"""Return True when the in-process regex fallback can enforce a deadline.
|
|
228
|
+
|
|
229
|
+
``inline_python_grep`` uses ``SIGALRM``, which exists only on POSIX and is
|
|
230
|
+
delivered only to the main thread. The agent runs adjacent read-only tools
|
|
231
|
+
in worker threads, so callers must not pick that fallback when the guard is
|
|
232
|
+
unavailable.
|
|
233
|
+
"""
|
|
234
|
+
return hasattr(signal, "SIGALRM") and (
|
|
235
|
+
threading.current_thread() is threading.main_thread()
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def run_bounded_command(
|
|
240
|
+
command: list[str],
|
|
241
|
+
*,
|
|
242
|
+
cwd: Path,
|
|
243
|
+
limit: int,
|
|
244
|
+
timeout: float = _SEARCH_TIMEOUT_SECONDS,
|
|
245
|
+
sandbox: SandboxRunner | None = None,
|
|
246
|
+
) -> tuple[int, list[str], bool, str]:
|
|
247
|
+
"""Run a search process while keeping only ``limit`` output lines.
|
|
248
|
+
|
|
249
|
+
The result is ``(returncode, lines, truncated, stderr)``. Reading stops as
|
|
250
|
+
soon as one extra line proves the result is truncated, then the child is
|
|
251
|
+
killed so a repository-wide search cannot outrun the caller.
|
|
252
|
+
"""
|
|
253
|
+
if sandbox is not None:
|
|
254
|
+
result = sandbox.run_argv(
|
|
255
|
+
command,
|
|
256
|
+
cwd=cwd,
|
|
257
|
+
timeout=timeout,
|
|
258
|
+
max_output_chars=_SEARCH_MAX_OUTPUT_CHARS,
|
|
259
|
+
)
|
|
260
|
+
if result.get("status") == "timeout":
|
|
261
|
+
raise ToolError(f"search timed out after {timeout:g} seconds")
|
|
262
|
+
if result.get("status") == "error" and result.get("exit_code") is None:
|
|
263
|
+
detail = str(
|
|
264
|
+
result.get("message") or result.get("stderr") or "search failed"
|
|
265
|
+
)
|
|
266
|
+
raise ToolError(detail)
|
|
267
|
+
# `str.splitlines()` also breaks on form feeds, U+2028, and a lone CR
|
|
268
|
+
# inside a matched line, so the tail of the line was dropped as if it
|
|
269
|
+
# were noise. Search output is newline-delimited.
|
|
270
|
+
lines = [
|
|
271
|
+
line.rstrip("\r")
|
|
272
|
+
for line in str(result.get("stdout") or "").split("\n")
|
|
273
|
+
if line
|
|
274
|
+
]
|
|
275
|
+
truncated = bool(result.get("truncated")) or len(lines) > limit
|
|
276
|
+
stderr = str(result.get("stderr") or "").strip()
|
|
277
|
+
return int(result.get("exit_code", 0) or 0), lines[:limit], truncated, stderr
|
|
278
|
+
|
|
279
|
+
timed_out = threading.Event()
|
|
280
|
+
process: subprocess.Popen[bytes] | None = None
|
|
281
|
+
with tempfile.TemporaryFile() as err_file:
|
|
282
|
+
try:
|
|
283
|
+
process = subprocess.Popen(
|
|
284
|
+
command,
|
|
285
|
+
cwd=cwd,
|
|
286
|
+
stdin=subprocess.DEVNULL,
|
|
287
|
+
stdout=subprocess.PIPE,
|
|
288
|
+
stderr=err_file,
|
|
289
|
+
# A process group lets the timeout kill grandchildren that keep
|
|
290
|
+
# the stdout pipe open after the direct child exited.
|
|
291
|
+
start_new_session=os.name != "nt",
|
|
292
|
+
)
|
|
293
|
+
except (OSError, ValueError) as exc:
|
|
294
|
+
raise ToolError(f"search failed: {exc}") from exc
|
|
295
|
+
|
|
296
|
+
assert process.stdout is not None
|
|
297
|
+
# Reading in a daemon thread keeps a grandchild that inherited stdout
|
|
298
|
+
# from blocking the caller past the deadline. The direct child can exit
|
|
299
|
+
# first, and on Windows the surviving grandchild is no longer reachable
|
|
300
|
+
# through taskkill's process tree.
|
|
301
|
+
output: queue.Queue[bytes | None] = queue.Queue()
|
|
302
|
+
reader_done = threading.Event()
|
|
303
|
+
|
|
304
|
+
def read_output() -> None:
|
|
305
|
+
try:
|
|
306
|
+
for _ in range(limit + 2):
|
|
307
|
+
raw = process.stdout.readline()
|
|
308
|
+
if not raw:
|
|
309
|
+
break
|
|
310
|
+
output.put(raw)
|
|
311
|
+
except (OSError, ValueError):
|
|
312
|
+
pass
|
|
313
|
+
finally:
|
|
314
|
+
output.put(None)
|
|
315
|
+
reader_done.set()
|
|
316
|
+
|
|
317
|
+
reader = threading.Thread(target=read_output, daemon=True)
|
|
318
|
+
reader.start()
|
|
319
|
+
lines: list[str] = []
|
|
320
|
+
truncated = False
|
|
321
|
+
deadline = time.monotonic() + timeout
|
|
322
|
+
while len(lines) <= limit:
|
|
323
|
+
remaining = deadline - time.monotonic()
|
|
324
|
+
if remaining <= 0:
|
|
325
|
+
timed_out.set()
|
|
326
|
+
break
|
|
327
|
+
try:
|
|
328
|
+
raw = output.get(timeout=remaining)
|
|
329
|
+
except queue.Empty:
|
|
330
|
+
timed_out.set()
|
|
331
|
+
break
|
|
332
|
+
if raw is None:
|
|
333
|
+
break
|
|
334
|
+
lines.append(raw.decode("utf-8", errors="replace").rstrip("\r\n"))
|
|
335
|
+
if len(lines) > limit:
|
|
336
|
+
truncated = True
|
|
337
|
+
lines = lines[:limit]
|
|
338
|
+
_kill_process_tree(process)
|
|
339
|
+
elif timed_out.is_set():
|
|
340
|
+
_kill_process_tree(process)
|
|
341
|
+
try:
|
|
342
|
+
process.wait(timeout=5)
|
|
343
|
+
except subprocess.TimeoutExpired:
|
|
344
|
+
timed_out.set()
|
|
345
|
+
_kill_process_tree(process)
|
|
346
|
+
# Closing the pipe is safe only once the reader stopped using it; a
|
|
347
|
+
# blocked read is left to its daemon thread instead of deadlocking.
|
|
348
|
+
if reader_done.wait(1.0):
|
|
349
|
+
process.stdout.close()
|
|
350
|
+
err_file.seek(0)
|
|
351
|
+
stderr = err_file.read(4000).decode("utf-8", errors="replace").strip()
|
|
352
|
+
if timed_out.is_set():
|
|
353
|
+
raise ToolError(f"search timed out after {timeout:g} seconds")
|
|
354
|
+
return process.returncode, lines, truncated, stderr
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _parse_python_grep(stdout: str, limit: int) -> dict[str, Any]:
|
|
358
|
+
"""Validate the fallback child's JSON and cap its match list."""
|
|
359
|
+
try:
|
|
360
|
+
parsed = json.loads(stdout)
|
|
361
|
+
except json.JSONDecodeError as exc:
|
|
362
|
+
raise ToolError("search returned an invalid result") from exc
|
|
363
|
+
if not isinstance(parsed, dict):
|
|
364
|
+
raise ToolError("search returned an invalid result")
|
|
365
|
+
if parsed.get("error"):
|
|
366
|
+
raise ToolError(str(parsed["error"]))
|
|
367
|
+
matches = parsed.get("matches")
|
|
368
|
+
if not isinstance(matches, list):
|
|
369
|
+
raise ToolError("search returned an invalid result")
|
|
370
|
+
return {
|
|
371
|
+
"status": "success",
|
|
372
|
+
"matches": [str(item) for item in matches[:limit]],
|
|
373
|
+
"truncated": bool(parsed.get("truncated")),
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def run_python_grep(
|
|
378
|
+
*,
|
|
379
|
+
pattern: str,
|
|
380
|
+
files: list[Path],
|
|
381
|
+
limit: int,
|
|
382
|
+
max_bytes: int,
|
|
383
|
+
sandbox: SandboxRunner | None = None,
|
|
384
|
+
cwd: Path | None = None,
|
|
385
|
+
) -> dict[str, Any]:
|
|
386
|
+
"""Run the regex fallback in a child process so a bad pattern can time out."""
|
|
387
|
+
spec = {
|
|
388
|
+
"pattern": pattern,
|
|
389
|
+
"ignore_case": smart_case_ignore(pattern),
|
|
390
|
+
"files": [str(path) for path in files],
|
|
391
|
+
"limit": limit,
|
|
392
|
+
"max_bytes": max_bytes,
|
|
393
|
+
"max_line_chars": min(max_bytes + 1, _MAX_READ_LINE_CHARS),
|
|
394
|
+
"max_columns": _MAX_GREP_LINE_CHARS,
|
|
395
|
+
}
|
|
396
|
+
script = [sys.executable, "-c", _PYTHON_GREP_SCRIPT]
|
|
397
|
+
if sandbox is not None:
|
|
398
|
+
# The child emits one bounded line per match, so the JSON envelope is
|
|
399
|
+
# larger than the default search cap. A truncated envelope is invalid
|
|
400
|
+
# JSON and used to surface as a bogus "invalid result" error.
|
|
401
|
+
output_cap = max(
|
|
402
|
+
_SEARCH_MAX_OUTPUT_CHARS,
|
|
403
|
+
limit * (_MAX_GREP_LINE_CHARS + 4096) + 4096,
|
|
404
|
+
)
|
|
405
|
+
result = sandbox.run_argv(
|
|
406
|
+
script,
|
|
407
|
+
cwd=cwd or Path.cwd(),
|
|
408
|
+
timeout=_SEARCH_TIMEOUT_SECONDS,
|
|
409
|
+
max_output_chars=output_cap,
|
|
410
|
+
stdin_text=json.dumps(spec),
|
|
411
|
+
)
|
|
412
|
+
if result.get("status") == "timeout":
|
|
413
|
+
raise ToolError("search timed out")
|
|
414
|
+
if result.get("status") == "error" and result.get("exit_code") is None:
|
|
415
|
+
detail = str(
|
|
416
|
+
result.get("message") or result.get("stderr") or "search failed"
|
|
417
|
+
)
|
|
418
|
+
raise ToolError(detail)
|
|
419
|
+
returncode = int(result.get("exit_code", 0) or 0)
|
|
420
|
+
stdout = str(result.get("stdout") or "")
|
|
421
|
+
stderr = str(result.get("stderr") or "").strip()
|
|
422
|
+
else:
|
|
423
|
+
try:
|
|
424
|
+
completed = subprocess.run(
|
|
425
|
+
script,
|
|
426
|
+
input=json.dumps(spec),
|
|
427
|
+
capture_output=True,
|
|
428
|
+
text=True,
|
|
429
|
+
encoding="utf-8",
|
|
430
|
+
timeout=_SEARCH_TIMEOUT_SECONDS,
|
|
431
|
+
check=False,
|
|
432
|
+
)
|
|
433
|
+
except subprocess.TimeoutExpired as exc:
|
|
434
|
+
raise ToolError("search timed out") from exc
|
|
435
|
+
except OSError as exc:
|
|
436
|
+
raise ToolError(f"search failed: {exc}") from exc
|
|
437
|
+
returncode = completed.returncode
|
|
438
|
+
stdout = completed.stdout
|
|
439
|
+
stderr = completed.stderr.strip()
|
|
440
|
+
if returncode != 0:
|
|
441
|
+
detail = stderr or f"search exited {returncode}"
|
|
442
|
+
raise ToolError(detail)
|
|
443
|
+
return _parse_python_grep(stdout, limit)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def inline_python_grep(
|
|
447
|
+
*,
|
|
448
|
+
pattern: str,
|
|
449
|
+
files: list[Path],
|
|
450
|
+
limit: int,
|
|
451
|
+
max_bytes: int,
|
|
452
|
+
) -> dict[str, Any]:
|
|
453
|
+
"""Pure-Python fallback used when the backend cannot run argv children.
|
|
454
|
+
|
|
455
|
+
A backend without argv support runs the regular expression in this process,
|
|
456
|
+
so a bounded alarm is the only thing that keeps a catastrophic pattern from
|
|
457
|
+
hanging the agent. Windows has no SIGALRM; the docker backend that needs
|
|
458
|
+
this path is POSIX-only, so the guard is best-effort elsewhere.
|
|
459
|
+
"""
|
|
460
|
+
flags = re.IGNORECASE if smart_case_ignore(pattern) else 0
|
|
461
|
+
try:
|
|
462
|
+
regex = re.compile(pattern, flags)
|
|
463
|
+
except re.error as exc:
|
|
464
|
+
raise ToolError(f"invalid regular expression: {exc}") from exc
|
|
465
|
+
|
|
466
|
+
use_alarm = (
|
|
467
|
+
hasattr(signal, "SIGALRM")
|
|
468
|
+
and threading.current_thread() is threading.main_thread()
|
|
469
|
+
)
|
|
470
|
+
previous_handler: Any = None
|
|
471
|
+
if use_alarm:
|
|
472
|
+
|
|
473
|
+
def _timeout(_signum: int, _frame: Any) -> None:
|
|
474
|
+
raise ToolError(
|
|
475
|
+
f"search timed out after {_SEARCH_TIMEOUT_SECONDS:g} seconds"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
previous_handler = signal.signal(signal.SIGALRM, _timeout)
|
|
479
|
+
signal.setitimer(signal.ITIMER_REAL, _SEARCH_TIMEOUT_SECONDS)
|
|
480
|
+
|
|
481
|
+
matches: list[str] = []
|
|
482
|
+
try:
|
|
483
|
+
for path in files:
|
|
484
|
+
try:
|
|
485
|
+
if path.stat().st_size > max_bytes:
|
|
486
|
+
continue
|
|
487
|
+
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
488
|
+
for number, line in enumerate(handle, start=1):
|
|
489
|
+
if "\x00" in line:
|
|
490
|
+
continue
|
|
491
|
+
# Match the content without the terminator so `$` and
|
|
492
|
+
# `\s` behave like the ripgrep engine.
|
|
493
|
+
text = line.rstrip("\r\n")
|
|
494
|
+
if not regex.search(text):
|
|
495
|
+
continue
|
|
496
|
+
if len(text) > _MAX_GREP_LINE_CHARS:
|
|
497
|
+
text = (
|
|
498
|
+
text[:_MAX_GREP_LINE_CHARS]
|
|
499
|
+
+ " [... omitted end of long line]"
|
|
500
|
+
)
|
|
501
|
+
matches.append(f"{path}:{number}:{text}")
|
|
502
|
+
if len(matches) > limit:
|
|
503
|
+
return {
|
|
504
|
+
"status": "success",
|
|
505
|
+
"matches": matches[:limit],
|
|
506
|
+
"truncated": True,
|
|
507
|
+
}
|
|
508
|
+
except (OSError, UnicodeError):
|
|
509
|
+
# An unreadable file must not abort a repository-wide search.
|
|
510
|
+
continue
|
|
511
|
+
finally:
|
|
512
|
+
if use_alarm:
|
|
513
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
514
|
+
# `getsignal` returns None when the handler was installed outside
|
|
515
|
+
# Python; passing that back raises TypeError and would leave our
|
|
516
|
+
# timeout handler installed for the rest of the process.
|
|
517
|
+
signal.signal(
|
|
518
|
+
signal.SIGALRM,
|
|
519
|
+
previous_handler if previous_handler is not None else signal.SIG_DFL,
|
|
520
|
+
)
|
|
521
|
+
return {"status": "success", "matches": matches[:limit], "truncated": False}
|