Coverage for merco/tools/file_tools.py: 32%
73 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
1"""文件操作工具 — 流式行读,支持 head/tail/翻页"""
3import logging
4from collections import deque
5from pathlib import Path
6from .base import BaseTool
8logger = logging.getLogger("merco.tools.file")
10_DEFAULT_LIMIT = 500 # 默认读取上限(行),覆盖大多数源文件,大文件用 offset 翻页
13class ReadFile(BaseTool):
14 """流式行读取 — 大文件不爆内存,默认上限防上下文溢出"""
16 name = "read_file"
17 description = (
18 "读取文件内容,默认返回前 500 行。大文件请用 offset/limit 翻页。\n"
19 "- offset: 起始行号(1-indexed),默认第 1 行\n"
20 "- limit: 读取行数,默认 500,设 0 表示不设上限(慎用)\n"
21 "- head: 读前 N 行,等价 offset=1, limit=N\n"
22 "- tail: 读最后 N 行"
23 )
24 toolset = "file"
25 parameters = {
26 "type": "object",
27 "properties": {
28 "path": {
29 "type": "string",
30 "description": "文件路径",
31 },
32 "offset": {
33 "type": "integer",
34 "description": "起始行号(1-indexed),默认 1",
35 },
36 "limit": {
37 "type": "integer",
38 "description": "读取行数,默认 500。设 0 取消上限(仅小文件)",
39 },
40 "head": {
41 "type": "integer",
42 "description": "读前 N 行,等价 offset=1, limit=N",
43 },
44 "tail": {
45 "type": "integer",
46 "description": "读最后 N 行",
47 },
48 },
49 "required": ["path"],
50 }
52 async def execute(self, path: str, limit: int = None, offset: int = None,
53 head: int = None, tail: int = None) -> dict:
54 file_path = Path(path)
55 if not file_path.exists():
56 return {"error": f"文件 '{path}' 不存在"}
57 if not file_path.is_file():
58 return {"error": f"'{path}' 不是文件"}
60 # ── 参数解析 ──
61 if tail is not None:
62 return self._read_tail(file_path, tail)
64 if head is not None:
65 offset = 1
66 limit = head
68 start_line = offset or 1
69 if start_line < 1:
70 start_line = 1
72 if limit is None:
73 limit = _DEFAULT_LIMIT
74 elif limit == 0:
75 limit = None # 0 = 不设上限
76 elif limit < 0:
77 limit = _DEFAULT_LIMIT
79 return self._read_by_lines(file_path, start_line, limit)
81 # ── 流式行读(不一次性加载全文件) ──
83 def _read_by_lines(self, file_path: Path, start_line: int, limit: int | None) -> dict:
84 """从 start_line 开始逐行读取,读到 limit 行或 EOF 即停"""
85 try:
86 with open(file_path, "r", encoding="utf-8", errors="replace") as f:
87 # 跳过 start_line - 1 行
88 for _ in range(start_line - 1):
89 if not f.readline():
90 return {
91 "content": "",
92 "hint": f"offset {start_line} 超出文件范围",
93 }
95 lines: list[str] = []
96 for line in f:
97 lines.append(line)
98 if limit is not None and len(lines) >= limit:
99 break
101 # 探测是否还有更多内容
102 has_more = bool(f.readline()) if limit is not None else False
103 except OSError as e:
104 return {"error": f"读取失败: {e}"}
106 content = "".join(lines)
107 end_line = start_line + len(lines) - 1
108 mtime = file_path.stat().st_mtime
110 hint = "" if not has_more else (
111 f"已返回 {start_line}-{end_line} 行,文件未完。"
112 f"用 offset={end_line + 1} 继续翻页。"
113 )
115 return {
116 "content": content,
117 "start_line": start_line,
118 "end_line": end_line,
119 "has_more": has_more,
120 "hint": hint,
121 "mtime": mtime,
122 }
124 # ── 读尾部 N 行 ──
126 def _read_tail(self, file_path: Path, n: int) -> dict:
127 """读取文件最后 N 行 — 用固定大小的双端队列,内存 O(n)"""
128 try:
129 with open(file_path, "r", encoding="utf-8", errors="replace") as f:
130 last_n = deque(f, maxlen=n)
131 except OSError as e:
132 return {"error": f"读取失败: {e}"}
134 content = "".join(last_n)
135 mtime = file_path.stat().st_mtime
137 return {
138 "content": content,
139 "lines": len(last_n),
140 "hint": f"文件最后 {len(last_n)} 行",
141 "mtime": mtime,
142 }
145class WriteFile(BaseTool):
146 """写入文件内容"""
148 name = "write_file"
149 description = (
150 "创建新文件或完全覆盖已有文件。\n"
151 "⚠️ 仅用于新建文件。修改已有文件请用 edit_file(SEARCH/REPLACE),"
152 "它会展示 diff 并等待确认。"
153 )
154 toolset = "file"
155 parameters = {
156 "type": "object",
157 "properties": {
158 "path": {"type": "string", "description": "文件路径"},
159 "content": {"type": "string", "description": "写入内容"},
160 },
161 "required": ["path", "content"],
162 }
164 async def execute(self, path: str, content: str) -> dict:
165 file_path = Path(path)
166 file_path.parent.mkdir(parents=True, exist_ok=True)
167 file_path.write_text(content)
168 return {"success": True, "path": str(path)}
171from .registry import tool_registry # noqa: E402 — 模块末尾自注册
172tool_registry.register(ReadFile())
173tool_registry.register(WriteFile())