fix: avoid python-ripgrep crash on Python 3.14 (#9244)

Co-authored-by: w33d <w33d@holdfast.local>
This commit is contained in:
w33d
2026-07-18 16:52:12 +08:00
committed by GitHub
parent 991a8127fc
commit 09a265ba16
4 changed files with 257 additions and 11 deletions

View File

@@ -9,7 +9,8 @@ import sys
from dataclasses import dataclass
from typing import Any
from python_ripgrep import search
if sys.version_info < (3, 14):
from python_ripgrep import search
from astrbot.api import logger
from astrbot.core.computer.file_read_utils import (
@@ -252,15 +253,80 @@ class LocalFileSystemComponent(FileSystemComponent):
before_context: int | None = None,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
results = search(
patterns=[pattern],
paths=[path] if path else None,
globs=[glob] if glob else None,
after_context=after_context,
before_context=before_context,
line_number=True,
if sys.version_info < (3, 14):
results = search(
patterns=[pattern],
paths=[path] if path else None,
globs=[glob] if glob else None,
after_context=after_context,
before_context=before_context,
line_number=True,
)
return {
"success": True,
"content": _truncate_long_lines("".join(results)),
}
rg_path = shutil.which("rg")
if not rg_path:
return {
"success": False,
"content": "",
"error": (
"The ripgrep (rg) executable is required for file search on "
"Python 3.14 or later because python-ripgrep 0.0.8 is "
"incompatible."
),
}
command = [rg_path, "--color=never", "-n", "-e", pattern]
if glob:
command.extend(["-g", glob])
if after_context is not None:
command.extend(["-A", str(after_context)])
if before_context is not None:
command.extend(["-B", str(before_context)])
command.extend(["--", path or "."])
try:
result = subprocess.run(
command,
capture_output=True,
timeout=30,
)
except subprocess.TimeoutExpired:
return {
"success": False,
"content": "",
"error": "File search timed out after 30 seconds.",
}
except OSError as exc:
return {
"success": False,
"content": "",
"error": f"Unable to start ripgrep: {exc}",
}
stdout = _decode_bytes_with_fallback(
result.stdout, preferred_encoding="utf-8"
)
return {"success": True, "content": _truncate_long_lines("".join(results))}
if result.returncode == 0:
return {
"success": True,
"content": _truncate_long_lines(stdout),
}
if result.returncode == 1:
return {"success": True, "content": ""}
stderr = _decode_bytes_with_fallback(
result.stderr, preferred_encoding="utf-8"
).strip()
return {
"success": False,
"content": "",
"error": stderr or f"ripgrep exited with code {result.returncode}",
"exit_code": result.returncode,
}
return await asyncio.to_thread(_run)

View File

@@ -65,7 +65,7 @@ dependencies = [
"python-socks>=2.8.0",
"pysocks>=1.7.1",
"packaging>=24.2",
"python-ripgrep==0.0.8",
"python-ripgrep==0.0.8 ; python_version < '3.14'",
"pyotp>=2.9.0",
]

View File

@@ -54,5 +54,5 @@ shipyard-neo-sdk>=0.2.0
packaging>=24.2
qrcode>=8.2
quart>=0.20.0
python-ripgrep==0.0.8
python-ripgrep==0.0.8 ; python_version < '3.14'
pyotp>=2.9.0

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
import asyncio
import subprocess
from pathlib import Path
import pytest
from astrbot.core.computer.booters import local as local_booter
from astrbot.core.computer.booters.local import LocalFileSystemComponent
@@ -53,3 +56,180 @@ def test_local_file_system_component_falls_back_to_gbk_on_windows(
assert result["success"] is True
assert result["content"] == "微博热搜"
def test_local_file_system_component_searches_with_rg_glob_and_context(monkeypatch):
calls = []
def fake_run(command, **kwargs):
calls.append((command, kwargs))
return subprocess.CompletedProcess(
command,
0,
stdout=b"src\\demo.py:4:needle\n",
stderr=b"",
)
monkeypatch.setattr(
local_booter.shutil,
"which",
lambda _executable: r"C:\tools\rg.exe",
)
monkeypatch.setattr(local_booter.sys, "version_info", (3, 14))
monkeypatch.setattr(local_booter.subprocess, "run", fake_run)
result = asyncio.run(
LocalFileSystemComponent().search_files(
"needle",
path=r"C:\workspace",
glob="*.py",
after_context=2,
before_context=1,
)
)
assert result == {"success": True, "content": "src\\demo.py:4:needle\n"}
assert calls == [
(
[
r"C:\tools\rg.exe",
"--color=never",
"-n",
"-e",
"needle",
"-g",
"*.py",
"-A",
"2",
"-B",
"1",
"--",
r"C:\workspace",
],
{
"capture_output": True,
"timeout": 30,
},
)
]
def test_local_file_system_component_treats_rg_no_match_as_success(monkeypatch):
monkeypatch.setattr(local_booter.shutil, "which", lambda _executable: "/bin/rg")
monkeypatch.setattr(local_booter.sys, "version_info", (3, 14))
monkeypatch.setattr(
local_booter.subprocess,
"run",
lambda command, **_kwargs: subprocess.CompletedProcess(
command,
1,
stdout=b"",
stderr=b"",
),
)
result = asyncio.run(LocalFileSystemComponent().search_files("missing"))
assert result == {"success": True, "content": ""}
def test_local_file_system_component_truncates_rg_long_lines_after_search(
monkeypatch,
):
long_line = b"result.py:1:" + (b"x" * 1200) + b"\n"
monkeypatch.setattr(local_booter.shutil, "which", lambda _executable: "/bin/rg")
monkeypatch.setattr(local_booter.sys, "version_info", (3, 14))
monkeypatch.setattr(
local_booter.subprocess,
"run",
lambda command, **_kwargs: subprocess.CompletedProcess(
command,
0,
stdout=long_line,
stderr=b"",
),
)
result = asyncio.run(LocalFileSystemComponent().search_files("x"))
assert result["success"] is True
assert result["content"] == long_line.decode()[:1000] + "\n"
def test_local_file_system_component_requires_rg_on_python_314(monkeypatch):
calls = []
monkeypatch.setattr(local_booter.shutil, "which", lambda _executable: None)
monkeypatch.setattr(local_booter.sys, "version_info", (3, 14))
monkeypatch.setattr(
local_booter.subprocess,
"run",
lambda *args, **kwargs: calls.append((args, kwargs)),
)
result = asyncio.run(LocalFileSystemComponent().search_files("needle"))
assert result == {
"success": False,
"content": "",
"error": (
"The ripgrep (rg) executable is required for file search on Python 3.14 "
"or later because python-ripgrep 0.0.8 is incompatible."
),
}
assert calls == []
def test_local_file_system_component_preserves_python_ripgrep_before_314(monkeypatch):
calls = []
def fake_search(**kwargs):
calls.append(kwargs)
return ["技能内容\n"]
monkeypatch.setattr(local_booter.sys, "version_info", (3, 13))
monkeypatch.setattr(local_booter, "search", fake_search)
monkeypatch.setattr(
local_booter.subprocess,
"run",
lambda *_args, **_kwargs: pytest.fail("subprocess should not be used"),
)
result = asyncio.run(
LocalFileSystemComponent().search_files(
"skill",
path="skills",
glob="*.md",
after_context=3,
before_context=2,
)
)
assert result == {"success": True, "content": "技能内容\n"}
assert calls == [
{
"patterns": ["skill"],
"paths": ["skills"],
"globs": ["*.md"],
"after_context": 3,
"before_context": 2,
"line_number": True,
}
]
def test_local_file_system_component_handles_search_timeout(monkeypatch):
def fake_run(command, **kwargs):
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
monkeypatch.setattr(local_booter.shutil, "which", lambda _executable: "/bin/rg")
monkeypatch.setattr(local_booter.sys, "version_info", (3, 14))
monkeypatch.setattr(local_booter.subprocess, "run", fake_run)
result = asyncio.run(LocalFileSystemComponent().search_files("needle"))
assert result == {
"success": False,
"content": "",
"error": "File search timed out after 30 seconds.",
}