agents-function-tools 0.2.0__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.
@@ -0,0 +1,433 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ from fnmatch import fnmatchcase
7
+ from hashlib import new as new_hash
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .errors import FoundationToolError
12
+
13
+
14
+ class Workspace:
15
+ """Filesystem operations confined to one configured directory."""
16
+
17
+ def __init__(
18
+ self,
19
+ root: str | Path,
20
+ *,
21
+ max_read_bytes: int = 1_000_000,
22
+ max_write_bytes: int = 1_000_000,
23
+ ) -> None:
24
+ resolved_root = Path(root).resolve(strict=True)
25
+ if not resolved_root.is_dir():
26
+ raise FoundationToolError("INVALID_ROOT", "Workspace root is not a directory.")
27
+ if max_read_bytes <= 0 or max_write_bytes <= 0:
28
+ raise FoundationToolError("INVALID_LIMIT", "File byte limits must be positive.")
29
+
30
+ self.root = resolved_root
31
+ self.max_read_bytes = max_read_bytes
32
+ self.max_write_bytes = max_write_bytes
33
+
34
+ def list_directory(
35
+ self, path: str = ".", *, recursive: bool = False, max_entries: int = 200
36
+ ) -> dict[str, Any]:
37
+ if not 1 <= max_entries <= 10_000:
38
+ raise FoundationToolError("INVALID_LIMIT", "max_entries must be between 1 and 10000.")
39
+ directory = self.resolve_directory(path)
40
+ entries: list[dict[str, Any]] = []
41
+ truncated = False
42
+
43
+ if recursive:
44
+ iterator = self._walk_entries(directory)
45
+ else:
46
+ iterator = iter(sorted(directory.iterdir(), key=lambda item: item.name.lower()))
47
+
48
+ for entry in iterator:
49
+ if len(entries) == max_entries:
50
+ truncated = True
51
+ break
52
+ entries.append(self._entry_metadata(entry))
53
+
54
+ return {
55
+ "path": self._relative(directory),
56
+ "entries": entries,
57
+ "truncated": truncated,
58
+ }
59
+
60
+ def read_text(self, path: str, *, max_bytes: int | None = None) -> dict[str, Any]:
61
+ file_path = self._resolve(path)
62
+ if not file_path.is_file():
63
+ raise FoundationToolError("NOT_A_FILE", "Path is not a regular file.")
64
+
65
+ limit = self.max_read_bytes if max_bytes is None else max_bytes
66
+ if not 1 <= limit <= self.max_read_bytes:
67
+ raise FoundationToolError(
68
+ "INVALID_LIMIT",
69
+ f"max_bytes must be between 1 and {self.max_read_bytes}.",
70
+ )
71
+ size = file_path.stat().st_size
72
+ if size > limit:
73
+ raise FoundationToolError(
74
+ "FILE_TOO_LARGE", f"File is {size} bytes; the read limit is {limit}."
75
+ )
76
+
77
+ try:
78
+ content = file_path.read_text(encoding="utf-8")
79
+ except UnicodeDecodeError as error:
80
+ raise FoundationToolError("NOT_UTF8", "Only UTF-8 text files can be read.") from error
81
+ except OSError as error:
82
+ raise FoundationToolError("READ_FAILED", str(error), retryable=True) from error
83
+
84
+ return {"path": self._relative(file_path), "content": content, "bytes": size}
85
+
86
+ def stat_path(self, path: str) -> dict[str, Any]:
87
+ """Return non-content metadata for one workspace path."""
88
+
89
+ target = self._resolve(path)
90
+ if not target.exists() and not target.is_symlink():
91
+ raise FoundationToolError("NOT_FOUND", "Path does not exist.")
92
+ metadata = self._entry_metadata(target)
93
+ try:
94
+ stat = target.lstat()
95
+ except OSError as error:
96
+ raise FoundationToolError("STAT_FAILED", str(error), retryable=True) from error
97
+ metadata.update(
98
+ {
99
+ "modified_at_epoch": stat.st_mtime,
100
+ "created_at_epoch": stat.st_ctime,
101
+ }
102
+ )
103
+ return metadata
104
+
105
+ def find_paths(
106
+ self,
107
+ pattern: str,
108
+ *,
109
+ path: str = ".",
110
+ kind: str = "any",
111
+ max_entries: int = 200,
112
+ ) -> dict[str, Any]:
113
+ """Find workspace entries using a case-insensitive glob-style filename pattern."""
114
+
115
+ if not pattern or "\x00" in pattern:
116
+ raise FoundationToolError("INVALID_PATTERN", "pattern must be a non-empty string.")
117
+ if kind not in {"any", "file", "directory", "symlink"}:
118
+ raise FoundationToolError(
119
+ "INVALID_KIND", "kind must be any, file, directory, or symlink."
120
+ )
121
+ if not 1 <= max_entries <= 10_000:
122
+ raise FoundationToolError("INVALID_LIMIT", "max_entries must be between 1 and 10000.")
123
+
124
+ directory = self.resolve_directory(path)
125
+ matches: list[dict[str, Any]] = []
126
+ truncated = False
127
+ for candidate in self._walk_entries(directory):
128
+ metadata = self._entry_metadata(candidate)
129
+ if kind != "any" and metadata["kind"] != kind:
130
+ continue
131
+ relative = self._relative(candidate)
132
+ if not (
133
+ fnmatchcase(relative.lower(), pattern.lower())
134
+ or fnmatchcase(Path(relative).name.lower(), pattern.lower())
135
+ ):
136
+ continue
137
+ if len(matches) == max_entries:
138
+ truncated = True
139
+ break
140
+ matches.append(metadata)
141
+ return {
142
+ "path": self._relative(directory),
143
+ "pattern": pattern,
144
+ "entries": matches,
145
+ "truncated": truncated,
146
+ }
147
+
148
+ def hash_file(self, path: str, *, algorithm: str = "sha256") -> dict[str, Any]:
149
+ """Calculate a bounded cryptographic hash for one regular file."""
150
+
151
+ if algorithm not in {"sha256", "sha512", "blake2b"}:
152
+ raise FoundationToolError(
153
+ "INVALID_ALGORITHM", "algorithm must be sha256, sha512, or blake2b."
154
+ )
155
+ file_path = self._resolve(path)
156
+ if not file_path.is_file():
157
+ raise FoundationToolError("NOT_A_FILE", "Path is not a regular file.")
158
+ size = file_path.stat().st_size
159
+ if size > self.max_read_bytes:
160
+ raise FoundationToolError(
161
+ "FILE_TOO_LARGE", f"File is {size} bytes; the hash limit is {self.max_read_bytes}."
162
+ )
163
+ digest = new_hash(algorithm)
164
+ try:
165
+ with file_path.open("rb") as handle:
166
+ for chunk in iter(lambda: handle.read(64 * 1024), b""):
167
+ digest.update(chunk)
168
+ except OSError as error:
169
+ raise FoundationToolError("HASH_FAILED", str(error), retryable=True) from error
170
+ return {
171
+ "path": self._relative(file_path),
172
+ "algorithm": algorithm,
173
+ "digest": digest.hexdigest(),
174
+ "bytes": size,
175
+ }
176
+
177
+ def write_text(
178
+ self,
179
+ path: str,
180
+ content: str,
181
+ *,
182
+ overwrite: bool = False,
183
+ create_parents: bool = False,
184
+ ) -> dict[str, Any]:
185
+ file_path = self._resolve(path, allow_root=False)
186
+ encoded = content.encode("utf-8")
187
+ if len(encoded) > self.max_write_bytes:
188
+ raise FoundationToolError(
189
+ "CONTENT_TOO_LARGE",
190
+ f"Content is {len(encoded)} bytes; the write limit is {self.max_write_bytes}.",
191
+ )
192
+ if file_path.exists() and file_path.is_dir():
193
+ raise FoundationToolError("IS_DIRECTORY", "Cannot write text to a directory.")
194
+ if file_path.exists() and not overwrite:
195
+ raise FoundationToolError(
196
+ "ALREADY_EXISTS", "File exists; set overwrite=true to replace it."
197
+ )
198
+
199
+ parent = file_path.parent
200
+ try:
201
+ if create_parents:
202
+ parent.mkdir(parents=True, exist_ok=True)
203
+ elif not parent.is_dir():
204
+ raise FoundationToolError("PARENT_NOT_FOUND", "Parent directory does not exist.")
205
+
206
+ temporary_name: str | None = None
207
+ with tempfile.NamedTemporaryFile(
208
+ mode="wb", dir=parent, prefix=".agent-write-", delete=False
209
+ ) as temporary:
210
+ temporary.write(encoded)
211
+ temporary.flush()
212
+ os.fsync(temporary.fileno())
213
+ temporary_name = temporary.name
214
+ os.replace(temporary_name, file_path)
215
+ except FoundationToolError:
216
+ raise
217
+ except OSError as error:
218
+ if "temporary_name" in locals() and temporary_name:
219
+ Path(temporary_name).unlink(missing_ok=True)
220
+ raise FoundationToolError("WRITE_FAILED", str(error), retryable=True) from error
221
+
222
+ return {
223
+ "path": self._relative(file_path),
224
+ "bytes": len(encoded),
225
+ "overwritten": overwrite,
226
+ }
227
+
228
+ def create_directory(
229
+ self, path: str, *, parents: bool = True, exist_ok: bool = False
230
+ ) -> dict[str, Any]:
231
+ directory = self._resolve(path, allow_root=False)
232
+ try:
233
+ directory.mkdir(parents=parents, exist_ok=exist_ok)
234
+ except FileExistsError as error:
235
+ raise FoundationToolError("ALREADY_EXISTS", "Path already exists.") from error
236
+ except FileNotFoundError as error:
237
+ raise FoundationToolError(
238
+ "PARENT_NOT_FOUND", "Parent directory does not exist."
239
+ ) from error
240
+ except OSError as error:
241
+ raise FoundationToolError("CREATE_FAILED", str(error)) from error
242
+ return {"path": self._relative(directory), "created": True}
243
+
244
+ def copy_file(
245
+ self,
246
+ source: str,
247
+ destination: str,
248
+ *,
249
+ overwrite: bool = False,
250
+ create_parents: bool = False,
251
+ ) -> dict[str, Any]:
252
+ """Copy one regular file without following source symlinks."""
253
+
254
+ source_path = self._resolve(source)
255
+ destination_path = self._resolve(destination, allow_root=False)
256
+ if not source_path.is_file() or source_path.is_symlink():
257
+ raise FoundationToolError("NOT_A_FILE", "source must be a regular non-symlink file.")
258
+ size = source_path.stat().st_size
259
+ if size > self.max_write_bytes:
260
+ raise FoundationToolError(
261
+ "CONTENT_TOO_LARGE",
262
+ f"Source is {size} bytes; the copy limit is {self.max_write_bytes}.",
263
+ )
264
+ if destination_path.exists() and not overwrite:
265
+ raise FoundationToolError(
266
+ "ALREADY_EXISTS", "Destination exists; set overwrite=true to replace it."
267
+ )
268
+ if destination_path.exists() and destination_path.is_dir():
269
+ raise FoundationToolError("IS_DIRECTORY", "Destination cannot be a directory.")
270
+ try:
271
+ if create_parents:
272
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
273
+ elif not destination_path.parent.is_dir():
274
+ raise FoundationToolError("PARENT_NOT_FOUND", "Parent directory does not exist.")
275
+ shutil.copy2(source_path, destination_path)
276
+ except FoundationToolError:
277
+ raise
278
+ except OSError as error:
279
+ raise FoundationToolError("COPY_FAILED", str(error), retryable=True) from error
280
+ return {
281
+ "source": self._relative(source_path),
282
+ "destination": self._relative(destination_path),
283
+ "bytes": size,
284
+ "overwritten": overwrite,
285
+ }
286
+
287
+ def move_path(
288
+ self,
289
+ source: str,
290
+ destination: str,
291
+ *,
292
+ overwrite: bool = False,
293
+ create_parents: bool = False,
294
+ ) -> dict[str, Any]:
295
+ """Move one workspace file or directory to a new workspace-relative path."""
296
+
297
+ source_path = self._resolve(source, allow_root=False)
298
+ destination_path = self._resolve(destination, allow_root=False)
299
+ if not source_path.exists() and not source_path.is_symlink():
300
+ raise FoundationToolError("NOT_FOUND", "Source path does not exist.")
301
+ if source_path == destination_path:
302
+ raise FoundationToolError("SAME_PATH", "Source and destination must differ.")
303
+ if destination_path.exists() or destination_path.is_symlink():
304
+ if not overwrite:
305
+ raise FoundationToolError(
306
+ "ALREADY_EXISTS", "Destination exists; set overwrite=true to replace it."
307
+ )
308
+ if destination_path.is_dir() and not destination_path.is_symlink():
309
+ raise FoundationToolError(
310
+ "DESTINATION_IS_DIRECTORY", "Cannot overwrite a destination directory."
311
+ )
312
+ try:
313
+ if create_parents:
314
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
315
+ elif not destination_path.parent.is_dir():
316
+ raise FoundationToolError("PARENT_NOT_FOUND", "Parent directory does not exist.")
317
+ if destination_path.exists() or destination_path.is_symlink():
318
+ destination_path.unlink()
319
+ shutil.move(str(source_path), str(destination_path))
320
+ except FoundationToolError:
321
+ raise
322
+ except OSError as error:
323
+ raise FoundationToolError("MOVE_FAILED", str(error), retryable=True) from error
324
+ return {
325
+ "source": self._relative(source_path),
326
+ "destination": self._relative(destination_path),
327
+ "moved": True,
328
+ }
329
+
330
+ def disk_usage(self) -> dict[str, Any]:
331
+ """Return storage capacity visible to the configured workspace."""
332
+
333
+ try:
334
+ usage = shutil.disk_usage(self.root)
335
+ except OSError as error:
336
+ raise FoundationToolError("DISK_USAGE_FAILED", str(error), retryable=True) from error
337
+ return {
338
+ "path": ".",
339
+ "total_bytes": usage.total,
340
+ "used_bytes": usage.used,
341
+ "free_bytes": usage.free,
342
+ }
343
+
344
+ def delete_path(self, path: str, *, recursive: bool = False) -> dict[str, Any]:
345
+ target = self._resolve(path, allow_root=False)
346
+ if not target.exists() and not target.is_symlink():
347
+ raise FoundationToolError("NOT_FOUND", "Path does not exist.")
348
+
349
+ kind = "file"
350
+ try:
351
+ if target.is_symlink():
352
+ kind = "symlink"
353
+ target.unlink()
354
+ elif target.is_dir():
355
+ kind = "directory"
356
+ if recursive:
357
+ shutil.rmtree(target)
358
+ else:
359
+ try:
360
+ target.rmdir()
361
+ except OSError as error:
362
+ raise FoundationToolError(
363
+ "DIRECTORY_NOT_EMPTY",
364
+ "Directory is not empty; set recursive=true to delete it.",
365
+ ) from error
366
+ else:
367
+ target.unlink()
368
+ except FoundationToolError:
369
+ raise
370
+ except OSError as error:
371
+ raise FoundationToolError("DELETE_FAILED", str(error), retryable=True) from error
372
+
373
+ return {"path": self._relative(target), "deleted": True, "kind": kind}
374
+
375
+ def resolve_directory(self, path: str) -> Path:
376
+ directory = self._resolve(path)
377
+ if not directory.is_dir():
378
+ raise FoundationToolError("NOT_A_DIRECTORY", "Path is not a directory.")
379
+ return directory
380
+
381
+ def relative_path(self, path: Path) -> str:
382
+ """Return a workspace-relative display path for an already resolved path."""
383
+
384
+ return self._relative(path)
385
+
386
+ def _resolve(self, path: str, *, allow_root: bool = True) -> Path:
387
+ if not path or "\x00" in path:
388
+ raise FoundationToolError("INVALID_PATH", "Path must be a non-empty string.")
389
+ relative = Path(path)
390
+ if relative.is_absolute():
391
+ raise FoundationToolError("ABSOLUTE_PATH", "Absolute paths are not allowed.")
392
+
393
+ candidate = (self.root / relative).resolve(strict=False)
394
+ try:
395
+ candidate.relative_to(self.root)
396
+ except ValueError as error:
397
+ raise FoundationToolError(
398
+ "PATH_OUTSIDE_WORKSPACE", "Path resolves outside the workspace."
399
+ ) from error
400
+ if not allow_root and candidate == self.root:
401
+ raise FoundationToolError(
402
+ "WORKSPACE_ROOT_PROTECTED", "The workspace root cannot be modified or deleted."
403
+ )
404
+ return candidate
405
+
406
+ def _walk_entries(self, directory: Path):
407
+ for current, directories, files in os.walk(directory, followlinks=False):
408
+ current_path = Path(current)
409
+ directories.sort(key=str.lower)
410
+ files.sort(key=str.lower)
411
+ for name in directories:
412
+ yield current_path / name
413
+ for name in files:
414
+ yield current_path / name
415
+
416
+ def _entry_metadata(self, entry: Path) -> dict[str, Any]:
417
+ if entry.is_symlink():
418
+ kind = "symlink"
419
+ size = None
420
+ elif entry.is_dir():
421
+ kind = "directory"
422
+ size = None
423
+ elif entry.is_file():
424
+ kind = "file"
425
+ size = entry.stat().st_size
426
+ else:
427
+ kind = "other"
428
+ size = None
429
+ return {"path": self._relative(entry), "kind": kind, "bytes": size}
430
+
431
+ def _relative(self, path: Path) -> str:
432
+ relative = path.relative_to(self.root)
433
+ return "." if not relative.parts else relative.as_posix()