mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-19 10:22:04 +08:00
fix: avoid python-ripgrep crash on Python 3.14 (#9244)
Co-authored-by: w33d <w33d@holdfast.local>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user