deepagents 0.2.4__py3-none-any.whl → 0.2.6__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.
- deepagents/backends/__init__.py +1 -1
- deepagents/backends/composite.py +66 -41
- deepagents/backends/filesystem.py +92 -86
- deepagents/backends/protocol.py +87 -13
- deepagents/backends/sandbox.py +341 -0
- deepagents/backends/state.py +59 -58
- deepagents/backends/store.py +73 -74
- deepagents/backends/utils.py +7 -21
- deepagents/graph.py +8 -4
- deepagents/middleware/filesystem.py +271 -66
- deepagents/middleware/resumable_shell.py +5 -4
- deepagents/middleware/subagents.py +8 -6
- {deepagents-0.2.4.dist-info → deepagents-0.2.6.dist-info}/METADATA +5 -10
- deepagents-0.2.6.dist-info/RECORD +19 -0
- deepagents-0.2.4.dist-info/RECORD +0 -19
- deepagents-0.2.4.dist-info/licenses/LICENSE +0 -21
- {deepagents-0.2.4.dist-info → deepagents-0.2.6.dist-info}/WHEEL +0 -0
- {deepagents-0.2.4.dist-info → deepagents-0.2.6.dist-info}/top_level.txt +0 -0
deepagents/backends/protocol.py
CHANGED
|
@@ -5,22 +5,45 @@ must follow. Backends can store files in different locations (state, filesystem,
|
|
|
5
5
|
database, etc.) and provide a uniform interface for file operations.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Protocol, TypeAlias, TypedDict, runtime_checkable
|
|
11
|
+
|
|
9
12
|
from langchain.tools import ToolRuntime
|
|
10
|
-
from deepagents.backends.utils import FileInfo, GrepMatch
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
|
|
15
|
+
class FileInfo(TypedDict, total=False):
|
|
16
|
+
"""Structured file listing info.
|
|
17
|
+
|
|
18
|
+
Minimal contract used across backends. Only "path" is required.
|
|
19
|
+
Other fields are best-effort and may be absent depending on backend.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
path: str
|
|
23
|
+
is_dir: bool
|
|
24
|
+
size: int # bytes (approx)
|
|
25
|
+
modified_at: str # ISO timestamp if known
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GrepMatch(TypedDict):
|
|
29
|
+
"""Structured grep match entry."""
|
|
30
|
+
|
|
31
|
+
path: str
|
|
32
|
+
line: int
|
|
33
|
+
text: str
|
|
13
34
|
|
|
14
35
|
|
|
15
36
|
@dataclass
|
|
16
37
|
class WriteResult:
|
|
17
38
|
"""Result from backend write operations.
|
|
39
|
+
|
|
18
40
|
Attributes:
|
|
19
41
|
error: Error message on failure, None on success.
|
|
20
42
|
path: Absolute path of written file, None on failure.
|
|
21
43
|
files_update: State update dict for checkpoint backends, None for external storage.
|
|
22
44
|
Checkpoint backends populate this with {file_path: file_data} for LangGraph state.
|
|
23
45
|
External backends set None (already persisted to disk/S3/database/etc).
|
|
46
|
+
|
|
24
47
|
Examples:
|
|
25
48
|
>>> # Checkpoint storage
|
|
26
49
|
>>> WriteResult(path="/f.txt", files_update={"/f.txt": {...}})
|
|
@@ -38,6 +61,7 @@ class WriteResult:
|
|
|
38
61
|
@dataclass
|
|
39
62
|
class EditResult:
|
|
40
63
|
"""Result from backend edit operations.
|
|
64
|
+
|
|
41
65
|
Attributes:
|
|
42
66
|
error: Error message on failure, None on success.
|
|
43
67
|
path: Absolute path of edited file, None on failure.
|
|
@@ -45,6 +69,7 @@ class EditResult:
|
|
|
45
69
|
Checkpoint backends populate this with {file_path: file_data} for LangGraph state.
|
|
46
70
|
External backends set None (already persisted to disk/S3/database/etc).
|
|
47
71
|
occurrences: Number of replacements made, None on failure.
|
|
72
|
+
|
|
48
73
|
Examples:
|
|
49
74
|
>>> # Checkpoint storage
|
|
50
75
|
>>> EditResult(path="/f.txt", files_update={"/f.txt": {...}}, occurrences=1)
|
|
@@ -59,6 +84,7 @@ class EditResult:
|
|
|
59
84
|
files_update: dict[str, Any] | None = None
|
|
60
85
|
occurrences: int | None = None
|
|
61
86
|
|
|
87
|
+
|
|
62
88
|
@runtime_checkable
|
|
63
89
|
class BackendProtocol(Protocol):
|
|
64
90
|
"""Protocol for pluggable memory backends (single, unified).
|
|
@@ -90,8 +116,8 @@ class BackendProtocol(Protocol):
|
|
|
90
116
|
def grep_raw(
|
|
91
117
|
self,
|
|
92
118
|
pattern: str,
|
|
93
|
-
path:
|
|
94
|
-
glob:
|
|
119
|
+
path: str | None = None,
|
|
120
|
+
glob: str | None = None,
|
|
95
121
|
) -> list["GrepMatch"] | str:
|
|
96
122
|
"""Structured search results or error string for invalid input."""
|
|
97
123
|
...
|
|
@@ -101,22 +127,70 @@ class BackendProtocol(Protocol):
|
|
|
101
127
|
...
|
|
102
128
|
|
|
103
129
|
def write(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
130
|
+
self,
|
|
131
|
+
file_path: str,
|
|
132
|
+
content: str,
|
|
107
133
|
) -> WriteResult:
|
|
108
134
|
"""Create a new file. Returns WriteResult; error populated on failure."""
|
|
109
135
|
...
|
|
110
136
|
|
|
111
137
|
def edit(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
self,
|
|
139
|
+
file_path: str,
|
|
140
|
+
old_string: str,
|
|
141
|
+
new_string: str,
|
|
142
|
+
replace_all: bool = False,
|
|
117
143
|
) -> EditResult:
|
|
118
144
|
"""Edit a file by replacing string occurrences. Returns EditResult."""
|
|
119
145
|
...
|
|
120
146
|
|
|
121
147
|
|
|
148
|
+
@dataclass
|
|
149
|
+
class ExecuteResponse:
|
|
150
|
+
"""Result of code execution.
|
|
151
|
+
|
|
152
|
+
Simplified schema optimized for LLM consumption.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
output: str
|
|
156
|
+
"""Combined stdout and stderr output of the executed command."""
|
|
157
|
+
|
|
158
|
+
exit_code: int | None = None
|
|
159
|
+
"""The process exit code. 0 indicates success, non-zero indicates failure."""
|
|
160
|
+
|
|
161
|
+
truncated: bool = False
|
|
162
|
+
"""Whether the output was truncated due to backend limitations."""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@runtime_checkable
|
|
166
|
+
class SandboxBackendProtocol(BackendProtocol, Protocol):
|
|
167
|
+
"""Protocol for sandboxed backends with isolated runtime.
|
|
168
|
+
|
|
169
|
+
Sandboxed backends run in isolated environments (e.g., separate processes,
|
|
170
|
+
containers) and communicate via defined interfaces.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def execute(
|
|
174
|
+
self,
|
|
175
|
+
command: str,
|
|
176
|
+
) -> ExecuteResponse:
|
|
177
|
+
"""Execute a command in the process.
|
|
178
|
+
|
|
179
|
+
Simplified interface optimized for LLM consumption.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
command: Full shell command string to execute.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
ExecuteResponse with combined output, exit code, optional signal, and truncation flag.
|
|
186
|
+
"""
|
|
187
|
+
...
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def id(self) -> str:
|
|
191
|
+
"""Unique identifier for the sandbox backend."""
|
|
192
|
+
...
|
|
193
|
+
|
|
194
|
+
|
|
122
195
|
BackendFactory: TypeAlias = Callable[[ToolRuntime], BackendProtocol]
|
|
196
|
+
BACKEND_TYPES = BackendProtocol | BackendFactory
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Base sandbox implementation with execute() as the only abstract method.
|
|
2
|
+
|
|
3
|
+
This module provides a base class that implements all SandboxBackendProtocol
|
|
4
|
+
methods using shell commands executed via execute(). Concrete implementations
|
|
5
|
+
only need to implement the execute() method.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
|
|
14
|
+
from deepagents.backends.protocol import (
|
|
15
|
+
EditResult,
|
|
16
|
+
ExecuteResponse,
|
|
17
|
+
FileInfo,
|
|
18
|
+
GrepMatch,
|
|
19
|
+
SandboxBackendProtocol,
|
|
20
|
+
WriteResult,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_GLOB_COMMAND_TEMPLATE = """python3 -c "
|
|
24
|
+
import glob
|
|
25
|
+
import os
|
|
26
|
+
import json
|
|
27
|
+
import base64
|
|
28
|
+
|
|
29
|
+
# Decode base64-encoded parameters
|
|
30
|
+
path = base64.b64decode('{path_b64}').decode('utf-8')
|
|
31
|
+
pattern = base64.b64decode('{pattern_b64}').decode('utf-8')
|
|
32
|
+
|
|
33
|
+
os.chdir(path)
|
|
34
|
+
matches = sorted(glob.glob(pattern, recursive=True))
|
|
35
|
+
for m in matches:
|
|
36
|
+
stat = os.stat(m)
|
|
37
|
+
result = {{
|
|
38
|
+
'path': m,
|
|
39
|
+
'size': stat.st_size,
|
|
40
|
+
'mtime': stat.st_mtime,
|
|
41
|
+
'is_dir': os.path.isdir(m)
|
|
42
|
+
}}
|
|
43
|
+
print(json.dumps(result))
|
|
44
|
+
" 2>/dev/null"""
|
|
45
|
+
|
|
46
|
+
_WRITE_COMMAND_TEMPLATE = """python3 -c "
|
|
47
|
+
import os
|
|
48
|
+
import sys
|
|
49
|
+
import base64
|
|
50
|
+
|
|
51
|
+
file_path = '{file_path}'
|
|
52
|
+
|
|
53
|
+
# Check if file already exists (atomic with write)
|
|
54
|
+
if os.path.exists(file_path):
|
|
55
|
+
print(f'Error: File \\'{file_path}\\' already exists', file=sys.stderr)
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
# Create parent directory if needed
|
|
59
|
+
parent_dir = os.path.dirname(file_path) or '.'
|
|
60
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
# Decode and write content
|
|
63
|
+
content = base64.b64decode('{content_b64}').decode('utf-8')
|
|
64
|
+
with open(file_path, 'w') as f:
|
|
65
|
+
f.write(content)
|
|
66
|
+
" 2>&1"""
|
|
67
|
+
|
|
68
|
+
_EDIT_COMMAND_TEMPLATE = """python3 -c "
|
|
69
|
+
import sys
|
|
70
|
+
import base64
|
|
71
|
+
|
|
72
|
+
# Read file content
|
|
73
|
+
with open('{file_path}', 'r') as f:
|
|
74
|
+
text = f.read()
|
|
75
|
+
|
|
76
|
+
# Decode base64-encoded strings
|
|
77
|
+
old = base64.b64decode('{old_b64}').decode('utf-8')
|
|
78
|
+
new = base64.b64decode('{new_b64}').decode('utf-8')
|
|
79
|
+
|
|
80
|
+
# Count occurrences
|
|
81
|
+
count = text.count(old)
|
|
82
|
+
|
|
83
|
+
# Exit with error codes if issues found
|
|
84
|
+
if count == 0:
|
|
85
|
+
sys.exit(1) # String not found
|
|
86
|
+
elif count > 1 and not {replace_all}:
|
|
87
|
+
sys.exit(2) # Multiple occurrences without replace_all
|
|
88
|
+
|
|
89
|
+
# Perform replacement
|
|
90
|
+
if {replace_all}:
|
|
91
|
+
result = text.replace(old, new)
|
|
92
|
+
else:
|
|
93
|
+
result = text.replace(old, new, 1)
|
|
94
|
+
|
|
95
|
+
# Write back to file
|
|
96
|
+
with open('{file_path}', 'w') as f:
|
|
97
|
+
f.write(result)
|
|
98
|
+
|
|
99
|
+
print(count)
|
|
100
|
+
" 2>&1"""
|
|
101
|
+
|
|
102
|
+
_READ_COMMAND_TEMPLATE = """python3 -c "
|
|
103
|
+
import os
|
|
104
|
+
import sys
|
|
105
|
+
|
|
106
|
+
file_path = '{file_path}'
|
|
107
|
+
offset = {offset}
|
|
108
|
+
limit = {limit}
|
|
109
|
+
|
|
110
|
+
# Check if file exists
|
|
111
|
+
if not os.path.isfile(file_path):
|
|
112
|
+
print('Error: File not found')
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
# Check if file is empty
|
|
116
|
+
if os.path.getsize(file_path) == 0:
|
|
117
|
+
print('System reminder: File exists but has empty contents')
|
|
118
|
+
sys.exit(0)
|
|
119
|
+
|
|
120
|
+
# Read file with offset and limit
|
|
121
|
+
with open(file_path, 'r') as f:
|
|
122
|
+
lines = f.readlines()
|
|
123
|
+
|
|
124
|
+
# Apply offset and limit
|
|
125
|
+
start_idx = offset
|
|
126
|
+
end_idx = offset + limit
|
|
127
|
+
selected_lines = lines[start_idx:end_idx]
|
|
128
|
+
|
|
129
|
+
# Format with line numbers (1-indexed, starting from offset + 1)
|
|
130
|
+
for i, line in enumerate(selected_lines):
|
|
131
|
+
line_num = offset + i + 1
|
|
132
|
+
# Remove trailing newline for formatting, then add it back
|
|
133
|
+
line_content = line.rstrip('\\n')
|
|
134
|
+
print(f'{{line_num:6d}}\\t{{line_content}}')
|
|
135
|
+
" 2>&1"""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class BaseSandbox(SandboxBackendProtocol, ABC):
|
|
139
|
+
"""Base sandbox implementation with execute() as abstract method.
|
|
140
|
+
|
|
141
|
+
This class provides default implementations for all protocol methods
|
|
142
|
+
using shell commands. Subclasses only need to implement execute().
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
@abstractmethod
|
|
146
|
+
def execute(
|
|
147
|
+
self,
|
|
148
|
+
command: str,
|
|
149
|
+
) -> ExecuteResponse:
|
|
150
|
+
"""Execute a command in the sandbox and return ExecuteResponse.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
command: Full shell command string to execute.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
ExecuteResponse with combined output, exit code, optional signal, and truncation flag.
|
|
157
|
+
"""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
def ls_info(self, path: str) -> list[FileInfo]:
|
|
161
|
+
"""Structured listing with file metadata using os.scandir."""
|
|
162
|
+
cmd = f"""python3 -c "
|
|
163
|
+
import os
|
|
164
|
+
import json
|
|
165
|
+
|
|
166
|
+
path = '{path}'
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
with os.scandir(path) as it:
|
|
170
|
+
for entry in it:
|
|
171
|
+
result = {{
|
|
172
|
+
'path': entry.name,
|
|
173
|
+
'is_dir': entry.is_dir(follow_symlinks=False)
|
|
174
|
+
}}
|
|
175
|
+
print(json.dumps(result))
|
|
176
|
+
except FileNotFoundError:
|
|
177
|
+
pass
|
|
178
|
+
except PermissionError:
|
|
179
|
+
pass
|
|
180
|
+
" 2>/dev/null"""
|
|
181
|
+
|
|
182
|
+
result = self.execute(cmd)
|
|
183
|
+
|
|
184
|
+
file_infos: list[FileInfo] = []
|
|
185
|
+
for line in result.output.strip().split("\n"):
|
|
186
|
+
if not line:
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
data = json.loads(line)
|
|
190
|
+
file_infos.append({"path": data["path"], "is_dir": data["is_dir"]})
|
|
191
|
+
except json.JSONDecodeError:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
return file_infos
|
|
195
|
+
|
|
196
|
+
def read(
|
|
197
|
+
self,
|
|
198
|
+
file_path: str,
|
|
199
|
+
offset: int = 0,
|
|
200
|
+
limit: int = 2000,
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Read file content with line numbers using a single shell command."""
|
|
203
|
+
# Use template for reading file with offset and limit
|
|
204
|
+
cmd = _READ_COMMAND_TEMPLATE.format(file_path=file_path, offset=offset, limit=limit)
|
|
205
|
+
result = self.execute(cmd)
|
|
206
|
+
|
|
207
|
+
output = result.output.rstrip()
|
|
208
|
+
exit_code = result.exit_code
|
|
209
|
+
|
|
210
|
+
if exit_code != 0 or "Error: File not found" in output:
|
|
211
|
+
return f"Error: File '{file_path}' not found"
|
|
212
|
+
|
|
213
|
+
return output
|
|
214
|
+
|
|
215
|
+
def write(
|
|
216
|
+
self,
|
|
217
|
+
file_path: str,
|
|
218
|
+
content: str,
|
|
219
|
+
) -> WriteResult:
|
|
220
|
+
"""Create a new file. Returns WriteResult; error populated on failure."""
|
|
221
|
+
# Encode content as base64 to avoid any escaping issues
|
|
222
|
+
content_b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
|
223
|
+
|
|
224
|
+
# Single atomic check + write command
|
|
225
|
+
cmd = _WRITE_COMMAND_TEMPLATE.format(file_path=file_path, content_b64=content_b64)
|
|
226
|
+
result = self.execute(cmd)
|
|
227
|
+
|
|
228
|
+
# Check for errors (exit code or error message in output)
|
|
229
|
+
if result.exit_code != 0 or "Error:" in result.output:
|
|
230
|
+
error_msg = result.output.strip() or f"Failed to write file '{file_path}'"
|
|
231
|
+
return WriteResult(error=error_msg)
|
|
232
|
+
|
|
233
|
+
# External storage - no files_update needed
|
|
234
|
+
return WriteResult(path=file_path, files_update=None)
|
|
235
|
+
|
|
236
|
+
def edit(
|
|
237
|
+
self,
|
|
238
|
+
file_path: str,
|
|
239
|
+
old_string: str,
|
|
240
|
+
new_string: str,
|
|
241
|
+
replace_all: bool = False,
|
|
242
|
+
) -> EditResult:
|
|
243
|
+
"""Edit a file by replacing string occurrences. Returns EditResult."""
|
|
244
|
+
# Encode strings as base64 to avoid any escaping issues
|
|
245
|
+
old_b64 = base64.b64encode(old_string.encode("utf-8")).decode("ascii")
|
|
246
|
+
new_b64 = base64.b64encode(new_string.encode("utf-8")).decode("ascii")
|
|
247
|
+
|
|
248
|
+
# Use template for string replacement
|
|
249
|
+
cmd = _EDIT_COMMAND_TEMPLATE.format(file_path=file_path, old_b64=old_b64, new_b64=new_b64, replace_all=replace_all)
|
|
250
|
+
result = self.execute(cmd)
|
|
251
|
+
|
|
252
|
+
exit_code = result.exit_code
|
|
253
|
+
output = result.output.strip()
|
|
254
|
+
|
|
255
|
+
if exit_code == 1:
|
|
256
|
+
return EditResult(error=f"Error: String not found in file: '{old_string}'")
|
|
257
|
+
if exit_code == 2:
|
|
258
|
+
return EditResult(error=f"Error: String '{old_string}' appears multiple times. Use replace_all=True to replace all occurrences.")
|
|
259
|
+
if exit_code != 0:
|
|
260
|
+
return EditResult(error=f"Error: File '{file_path}' not found")
|
|
261
|
+
|
|
262
|
+
count = int(output)
|
|
263
|
+
# External storage - no files_update needed
|
|
264
|
+
return EditResult(path=file_path, files_update=None, occurrences=count)
|
|
265
|
+
|
|
266
|
+
def grep_raw(
|
|
267
|
+
self,
|
|
268
|
+
pattern: str,
|
|
269
|
+
path: str | None = None,
|
|
270
|
+
glob: str | None = None,
|
|
271
|
+
) -> list[GrepMatch] | str:
|
|
272
|
+
"""Structured search results or error string for invalid input."""
|
|
273
|
+
search_path = path or "."
|
|
274
|
+
|
|
275
|
+
# Build grep command to get structured output
|
|
276
|
+
grep_opts = "-rHn" # recursive, with filename, with line number
|
|
277
|
+
|
|
278
|
+
# Add glob pattern if specified
|
|
279
|
+
glob_pattern = ""
|
|
280
|
+
if glob:
|
|
281
|
+
glob_pattern = f"--include='{glob}'"
|
|
282
|
+
|
|
283
|
+
# Escape pattern for shell
|
|
284
|
+
pattern_escaped = pattern.replace("'", "'\\\\''")
|
|
285
|
+
|
|
286
|
+
cmd = f"grep {grep_opts} {glob_pattern} -e '{pattern_escaped}' '{search_path}' 2>/dev/null || true"
|
|
287
|
+
result = self.execute(cmd)
|
|
288
|
+
|
|
289
|
+
output = result.output.rstrip()
|
|
290
|
+
if not output:
|
|
291
|
+
return []
|
|
292
|
+
|
|
293
|
+
# Parse grep output into GrepMatch objects
|
|
294
|
+
matches: list[GrepMatch] = []
|
|
295
|
+
for line in output.split("\n"):
|
|
296
|
+
# Format is: path:line_number:text
|
|
297
|
+
parts = line.split(":", 2)
|
|
298
|
+
if len(parts) >= 3:
|
|
299
|
+
matches.append(
|
|
300
|
+
{
|
|
301
|
+
"path": parts[0],
|
|
302
|
+
"line": int(parts[1]),
|
|
303
|
+
"text": parts[2],
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return matches
|
|
308
|
+
|
|
309
|
+
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
|
310
|
+
"""Structured glob matching returning FileInfo dicts."""
|
|
311
|
+
# Encode pattern and path as base64 to avoid escaping issues
|
|
312
|
+
pattern_b64 = base64.b64encode(pattern.encode("utf-8")).decode("ascii")
|
|
313
|
+
path_b64 = base64.b64encode(path.encode("utf-8")).decode("ascii")
|
|
314
|
+
|
|
315
|
+
cmd = _GLOB_COMMAND_TEMPLATE.format(path_b64=path_b64, pattern_b64=pattern_b64)
|
|
316
|
+
result = self.execute(cmd)
|
|
317
|
+
|
|
318
|
+
output = result.output.strip()
|
|
319
|
+
if not output:
|
|
320
|
+
return []
|
|
321
|
+
|
|
322
|
+
# Parse JSON output into FileInfo dicts
|
|
323
|
+
file_infos: list[FileInfo] = []
|
|
324
|
+
for line in output.split("\n"):
|
|
325
|
+
try:
|
|
326
|
+
data = json.loads(line)
|
|
327
|
+
file_infos.append(
|
|
328
|
+
{
|
|
329
|
+
"path": data["path"],
|
|
330
|
+
"is_dir": data["is_dir"],
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
except json.JSONDecodeError:
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
return file_infos
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
@abstractmethod
|
|
340
|
+
def id(self) -> str:
|
|
341
|
+
"""Unique identifier for this backend instance."""
|