agentfs-sdk 0.3.1__py3-none-any.whl → 0.4.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.
- agentfs_sdk/__init__.py +10 -2
- agentfs_sdk/constants.py +13 -0
- agentfs_sdk/errors.py +60 -0
- agentfs_sdk/filesystem.py +643 -51
- agentfs_sdk/guards.py +199 -0
- {agentfs_sdk-0.3.1.dist-info → agentfs_sdk-0.4.0.dist-info}/METADATA +4 -2
- agentfs_sdk-0.4.0.dist-info/RECORD +12 -0
- agentfs_sdk-0.3.1.dist-info/RECORD +0 -9
- {agentfs_sdk-0.3.1.dist-info → agentfs_sdk-0.4.0.dist-info}/WHEEL +0 -0
- {agentfs_sdk-0.3.1.dist-info → agentfs_sdk-0.4.0.dist-info}/top_level.txt +0 -0
agentfs_sdk/__init__.py
CHANGED
|
@@ -4,11 +4,12 @@ A filesystem and key-value store for AI agents, powered by SQLite.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from .agentfs import AgentFS, AgentFSOptions
|
|
7
|
-
from .
|
|
7
|
+
from .errors import ErrnoException, FsErrorCode, FsSyscall
|
|
8
|
+
from .filesystem import S_IFDIR, S_IFLNK, S_IFMT, S_IFREG, Filesystem, Stats
|
|
8
9
|
from .kvstore import KvStore
|
|
9
10
|
from .toolcalls import ToolCall, ToolCalls, ToolCallStats
|
|
10
11
|
|
|
11
|
-
__version__ = "0.
|
|
12
|
+
__version__ = "0.4.0"
|
|
12
13
|
|
|
13
14
|
__all__ = [
|
|
14
15
|
"AgentFS",
|
|
@@ -16,7 +17,14 @@ __all__ = [
|
|
|
16
17
|
"KvStore",
|
|
17
18
|
"Filesystem",
|
|
18
19
|
"Stats",
|
|
20
|
+
"S_IFDIR",
|
|
21
|
+
"S_IFLNK",
|
|
22
|
+
"S_IFMT",
|
|
23
|
+
"S_IFREG",
|
|
19
24
|
"ToolCalls",
|
|
20
25
|
"ToolCall",
|
|
21
26
|
"ToolCallStats",
|
|
27
|
+
"ErrnoException",
|
|
28
|
+
"FsErrorCode",
|
|
29
|
+
"FsSyscall",
|
|
22
30
|
]
|
agentfs_sdk/constants.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Filesystem constants"""
|
|
2
|
+
|
|
3
|
+
# File types for mode field
|
|
4
|
+
S_IFMT = 0o170000 # File type mask
|
|
5
|
+
S_IFREG = 0o100000 # Regular file
|
|
6
|
+
S_IFDIR = 0o040000 # Directory
|
|
7
|
+
S_IFLNK = 0o120000 # Symbolic link
|
|
8
|
+
|
|
9
|
+
# Default permissions
|
|
10
|
+
DEFAULT_FILE_MODE = S_IFREG | 0o644 # Regular file, rw-r--r--
|
|
11
|
+
DEFAULT_DIR_MODE = S_IFDIR | 0o755 # Directory, rwxr-xr-x
|
|
12
|
+
|
|
13
|
+
DEFAULT_CHUNK_SIZE = 4096
|
agentfs_sdk/errors.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Error types for filesystem operations"""
|
|
2
|
+
|
|
3
|
+
from typing import Literal, Optional
|
|
4
|
+
|
|
5
|
+
# POSIX-style error codes for filesystem operations
|
|
6
|
+
FsErrorCode = Literal[
|
|
7
|
+
"ENOENT", # No such file or directory
|
|
8
|
+
"EEXIST", # File already exists
|
|
9
|
+
"EISDIR", # Is a directory (when file expected)
|
|
10
|
+
"ENOTDIR", # Not a directory (when directory expected)
|
|
11
|
+
"ENOTEMPTY", # Directory not empty
|
|
12
|
+
"EPERM", # Operation not permitted
|
|
13
|
+
"EINVAL", # Invalid argument
|
|
14
|
+
"ENOSYS", # Function not implemented (use for symlinks)
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
# Filesystem syscall names for error reporting
|
|
18
|
+
# rm, scandir and copyfile are not actual syscalls but used for convenience
|
|
19
|
+
FsSyscall = Literal[
|
|
20
|
+
"open",
|
|
21
|
+
"stat",
|
|
22
|
+
"mkdir",
|
|
23
|
+
"rmdir",
|
|
24
|
+
"rm",
|
|
25
|
+
"unlink",
|
|
26
|
+
"rename",
|
|
27
|
+
"scandir",
|
|
28
|
+
"copyfile",
|
|
29
|
+
"access",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ErrnoException(Exception):
|
|
34
|
+
"""Exception with errno-style attributes
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
code: POSIX error code (e.g., 'ENOENT')
|
|
38
|
+
syscall: System call name (e.g., 'open')
|
|
39
|
+
path: Optional path involved in the error
|
|
40
|
+
message: Optional custom message (defaults to code)
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
>>> raise ErrnoException('ENOENT', 'open', '/missing.txt')
|
|
44
|
+
ErrnoException: ENOENT: no such file or directory, open '/missing.txt'
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
code: FsErrorCode,
|
|
50
|
+
syscall: FsSyscall,
|
|
51
|
+
path: Optional[str] = None,
|
|
52
|
+
message: Optional[str] = None,
|
|
53
|
+
):
|
|
54
|
+
base = message if message else code
|
|
55
|
+
suffix = f" '{path}'" if path is not None else ""
|
|
56
|
+
error_message = f"{code}: {base}, {syscall}{suffix}"
|
|
57
|
+
super().__init__(error_message)
|
|
58
|
+
self.code = code
|
|
59
|
+
self.syscall = syscall
|
|
60
|
+
self.path = path
|
agentfs_sdk/filesystem.py
CHANGED
|
@@ -6,17 +6,31 @@ from typing import List, Optional, Union
|
|
|
6
6
|
|
|
7
7
|
from turso.aio import Connection
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
9
|
+
from .constants import (
|
|
10
|
+
DEFAULT_CHUNK_SIZE,
|
|
11
|
+
DEFAULT_DIR_MODE,
|
|
12
|
+
DEFAULT_FILE_MODE,
|
|
13
|
+
S_IFDIR,
|
|
14
|
+
S_IFLNK,
|
|
15
|
+
S_IFMT,
|
|
16
|
+
S_IFREG,
|
|
17
|
+
)
|
|
18
|
+
from .errors import ErrnoException, FsSyscall
|
|
19
|
+
from .guards import (
|
|
20
|
+
assert_inode_is_directory,
|
|
21
|
+
assert_not_root,
|
|
22
|
+
assert_not_symlink_mode,
|
|
23
|
+
assert_readable_existing_inode,
|
|
24
|
+
assert_readdir_target_inode,
|
|
25
|
+
assert_unlink_target_inode,
|
|
26
|
+
assert_writable_existing_inode,
|
|
27
|
+
get_inode_mode_or_throw,
|
|
28
|
+
normalize_rm_options,
|
|
29
|
+
throw_enoent_unless_force,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Re-export constants for backwards compatibility
|
|
33
|
+
__all__ = ["Filesystem", "Stats", "S_IFMT", "S_IFREG", "S_IFDIR", "S_IFLNK"]
|
|
20
34
|
|
|
21
35
|
|
|
22
36
|
@dataclass
|
|
@@ -101,6 +115,7 @@ class Filesystem:
|
|
|
101
115
|
CREATE TABLE IF NOT EXISTS fs_inode (
|
|
102
116
|
ino INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
103
117
|
mode INTEGER NOT NULL,
|
|
118
|
+
nlink INTEGER NOT NULL DEFAULT 0,
|
|
104
119
|
uid INTEGER NOT NULL DEFAULT 0,
|
|
105
120
|
gid INTEGER NOT NULL DEFAULT 0,
|
|
106
121
|
size INTEGER NOT NULL DEFAULT 0,
|
|
@@ -161,8 +176,8 @@ class Filesystem:
|
|
|
161
176
|
now = int(time.time())
|
|
162
177
|
await self._db.execute(
|
|
163
178
|
"""
|
|
164
|
-
INSERT INTO fs_inode (ino, mode, uid, gid, size, atime, mtime, ctime)
|
|
165
|
-
VALUES (?, ?, 0, 0, 0, ?, ?, ?)
|
|
179
|
+
INSERT INTO fs_inode (ino, mode, nlink, uid, gid, size, atime, mtime, ctime)
|
|
180
|
+
VALUES (?, ?, 1, 0, 0, 0, ?, ?, ?)
|
|
166
181
|
""",
|
|
167
182
|
(self._root_ino, DEFAULT_DIR_MODE, now, now, now),
|
|
168
183
|
)
|
|
@@ -266,6 +281,11 @@ class Filesystem:
|
|
|
266
281
|
""",
|
|
267
282
|
(name, parent_ino, ino),
|
|
268
283
|
)
|
|
284
|
+
# Increment link count
|
|
285
|
+
await self._db.execute(
|
|
286
|
+
"UPDATE fs_inode SET nlink = nlink + 1 WHERE ino = ?",
|
|
287
|
+
(ino,),
|
|
288
|
+
)
|
|
269
289
|
await self._db.commit()
|
|
270
290
|
|
|
271
291
|
async def _ensure_parent_dirs(self, path: str) -> None:
|
|
@@ -301,16 +321,41 @@ class Filesystem:
|
|
|
301
321
|
|
|
302
322
|
async def _get_link_count(self, ino: int) -> int:
|
|
303
323
|
"""Get link count for an inode"""
|
|
304
|
-
cursor = await self._db.execute("SELECT
|
|
324
|
+
cursor = await self._db.execute("SELECT nlink FROM fs_inode WHERE ino = ?", (ino,))
|
|
305
325
|
result = await cursor.fetchone()
|
|
306
326
|
return result[0] if result else 0
|
|
307
327
|
|
|
308
|
-
async def
|
|
328
|
+
async def _get_inode_mode(self, ino: int) -> Optional[int]:
|
|
329
|
+
"""Get mode for an inode"""
|
|
330
|
+
cursor = await self._db.execute("SELECT mode FROM fs_inode WHERE ino = ?", (ino,))
|
|
331
|
+
row = await cursor.fetchone()
|
|
332
|
+
return row[0] if row else None
|
|
333
|
+
|
|
334
|
+
async def _resolve_path_or_throw(self, path: str, syscall: FsSyscall) -> tuple[str, int]:
|
|
335
|
+
"""Resolve path to inode or throw ENOENT"""
|
|
336
|
+
normalized_path = self._normalize_path(path)
|
|
337
|
+
ino = await self._resolve_path(normalized_path)
|
|
338
|
+
if ino is None:
|
|
339
|
+
raise ErrnoException(
|
|
340
|
+
code="ENOENT",
|
|
341
|
+
syscall=syscall,
|
|
342
|
+
path=normalized_path,
|
|
343
|
+
message="no such file or directory",
|
|
344
|
+
)
|
|
345
|
+
return (normalized_path, ino)
|
|
346
|
+
|
|
347
|
+
async def write_file(
|
|
348
|
+
self,
|
|
349
|
+
path: str,
|
|
350
|
+
content: Union[str, bytes],
|
|
351
|
+
encoding: str = "utf-8",
|
|
352
|
+
) -> None:
|
|
309
353
|
"""Write content to a file
|
|
310
354
|
|
|
311
355
|
Args:
|
|
312
356
|
path: Path to the file
|
|
313
357
|
content: Content to write (string or bytes)
|
|
358
|
+
encoding: Text encoding (default: 'utf-8')
|
|
314
359
|
|
|
315
360
|
Example:
|
|
316
361
|
>>> await fs.write_file('/data/config.json', '{"key": "value"}')
|
|
@@ -318,20 +363,31 @@ class Filesystem:
|
|
|
318
363
|
# Ensure parent directories exist
|
|
319
364
|
await self._ensure_parent_dirs(path)
|
|
320
365
|
|
|
366
|
+
normalized_path = self._normalize_path(path)
|
|
321
367
|
# Check if file already exists
|
|
322
|
-
ino = await self._resolve_path(
|
|
368
|
+
ino = await self._resolve_path(normalized_path)
|
|
323
369
|
|
|
324
370
|
if ino is not None:
|
|
371
|
+
# Validate existing inode
|
|
372
|
+
await assert_writable_existing_inode(self._db, ino, "open", normalized_path)
|
|
325
373
|
# Update existing file
|
|
326
|
-
await self._update_file_content(ino, content)
|
|
374
|
+
await self._update_file_content(ino, content, encoding)
|
|
327
375
|
else:
|
|
328
376
|
# Create new file
|
|
329
|
-
parent = await self._resolve_parent(
|
|
377
|
+
parent = await self._resolve_parent(normalized_path)
|
|
330
378
|
if not parent:
|
|
331
|
-
raise
|
|
379
|
+
raise ErrnoException(
|
|
380
|
+
code="ENOENT",
|
|
381
|
+
syscall="open",
|
|
382
|
+
path=normalized_path,
|
|
383
|
+
message="no such file or directory",
|
|
384
|
+
)
|
|
332
385
|
|
|
333
386
|
parent_ino, name = parent
|
|
334
387
|
|
|
388
|
+
# Ensure parent is a directory
|
|
389
|
+
await assert_inode_is_directory(self._db, parent_ino, "open", normalized_path)
|
|
390
|
+
|
|
335
391
|
# Create inode
|
|
336
392
|
file_ino = await self._create_inode(DEFAULT_FILE_MODE)
|
|
337
393
|
|
|
@@ -339,11 +395,13 @@ class Filesystem:
|
|
|
339
395
|
await self._create_dentry(parent_ino, name, file_ino)
|
|
340
396
|
|
|
341
397
|
# Write content
|
|
342
|
-
await self._update_file_content(file_ino, content)
|
|
398
|
+
await self._update_file_content(file_ino, content, encoding)
|
|
343
399
|
|
|
344
|
-
async def _update_file_content(
|
|
400
|
+
async def _update_file_content(
|
|
401
|
+
self, ino: int, content: Union[str, bytes], encoding: str = "utf-8"
|
|
402
|
+
) -> None:
|
|
345
403
|
"""Update file content"""
|
|
346
|
-
buffer = content.encode(
|
|
404
|
+
buffer = content.encode(encoding) if isinstance(content, str) else content
|
|
347
405
|
now = int(time.time())
|
|
348
406
|
|
|
349
407
|
# Delete existing data chunks
|
|
@@ -388,9 +446,9 @@ class Filesystem:
|
|
|
388
446
|
>>> content = await fs.read_file('/data/config.json')
|
|
389
447
|
>>> data = await fs.read_file('/data/image.png', encoding=None)
|
|
390
448
|
"""
|
|
391
|
-
ino = await self.
|
|
392
|
-
|
|
393
|
-
|
|
449
|
+
normalized_path, ino = await self._resolve_path_or_throw(path, "open")
|
|
450
|
+
|
|
451
|
+
await assert_readable_existing_inode(self._db, ino, "open", normalized_path)
|
|
394
452
|
|
|
395
453
|
# Get all data chunks
|
|
396
454
|
cursor = await self._db.execute(
|
|
@@ -432,9 +490,9 @@ class Filesystem:
|
|
|
432
490
|
>>> for entry in entries:
|
|
433
491
|
>>> print(entry)
|
|
434
492
|
"""
|
|
435
|
-
ino = await self.
|
|
436
|
-
|
|
437
|
-
|
|
493
|
+
normalized_path, ino = await self._resolve_path_or_throw(path, "scandir")
|
|
494
|
+
|
|
495
|
+
await assert_readdir_target_inode(self._db, ino, normalized_path)
|
|
438
496
|
|
|
439
497
|
# Get all directory entries
|
|
440
498
|
cursor = await self._db.execute(
|
|
@@ -449,23 +507,24 @@ class Filesystem:
|
|
|
449
507
|
|
|
450
508
|
return [row[0] for row in rows]
|
|
451
509
|
|
|
452
|
-
async def
|
|
453
|
-
"""Delete a file
|
|
510
|
+
async def unlink(self, path: str) -> None:
|
|
511
|
+
"""Delete a file (unlink)
|
|
454
512
|
|
|
455
513
|
Args:
|
|
456
514
|
path: Path to the file
|
|
457
515
|
|
|
458
516
|
Example:
|
|
459
|
-
>>> await fs.
|
|
517
|
+
>>> await fs.unlink('/data/temp.txt')
|
|
460
518
|
"""
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
519
|
+
normalized_path = self._normalize_path(path)
|
|
520
|
+
assert_not_root(normalized_path, "unlink")
|
|
521
|
+
normalized_path, ino = await self._resolve_path_or_throw(normalized_path, "unlink")
|
|
464
522
|
|
|
465
|
-
|
|
466
|
-
if not parent:
|
|
467
|
-
raise ValueError("Cannot delete root directory")
|
|
523
|
+
await assert_unlink_target_inode(self._db, ino, normalized_path)
|
|
468
524
|
|
|
525
|
+
parent = await self._resolve_parent(normalized_path)
|
|
526
|
+
# parent is guaranteed to exist here since normalized_path != '/'
|
|
527
|
+
assert parent is not None
|
|
469
528
|
parent_ino, name = parent
|
|
470
529
|
|
|
471
530
|
# Delete the directory entry
|
|
@@ -477,6 +536,12 @@ class Filesystem:
|
|
|
477
536
|
(parent_ino, name),
|
|
478
537
|
)
|
|
479
538
|
|
|
539
|
+
# Decrement link count
|
|
540
|
+
await self._db.execute(
|
|
541
|
+
"UPDATE fs_inode SET nlink = nlink - 1 WHERE ino = ?",
|
|
542
|
+
(ino,),
|
|
543
|
+
)
|
|
544
|
+
|
|
480
545
|
# Check if this was the last link to the inode
|
|
481
546
|
link_count = await self._get_link_count(ino)
|
|
482
547
|
if link_count == 0:
|
|
@@ -488,6 +553,18 @@ class Filesystem:
|
|
|
488
553
|
|
|
489
554
|
await self._db.commit()
|
|
490
555
|
|
|
556
|
+
# Backwards-compatible alias
|
|
557
|
+
async def delete_file(self, path: str) -> None:
|
|
558
|
+
"""Delete a file (deprecated, use unlink instead)
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
path: Path to the file
|
|
562
|
+
|
|
563
|
+
Example:
|
|
564
|
+
>>> await fs.delete_file('/data/temp.txt')
|
|
565
|
+
"""
|
|
566
|
+
return await self.unlink(path)
|
|
567
|
+
|
|
491
568
|
async def stat(self, path: str) -> Stats:
|
|
492
569
|
"""Get file/directory statistics
|
|
493
570
|
|
|
@@ -502,13 +579,11 @@ class Filesystem:
|
|
|
502
579
|
>>> print(f"Size: {stats.size} bytes")
|
|
503
580
|
>>> print(f"Is file: {stats.is_file()}")
|
|
504
581
|
"""
|
|
505
|
-
ino = await self.
|
|
506
|
-
if ino is None:
|
|
507
|
-
raise FileNotFoundError(f"ENOENT: no such file or directory, stat '{path}'")
|
|
582
|
+
normalized_path, ino = await self._resolve_path_or_throw(path, "stat")
|
|
508
583
|
|
|
509
584
|
cursor = await self._db.execute(
|
|
510
585
|
"""
|
|
511
|
-
SELECT ino, mode, uid, gid, size, atime, mtime, ctime
|
|
586
|
+
SELECT ino, mode, nlink, uid, gid, size, atime, mtime, ctime
|
|
512
587
|
FROM fs_inode
|
|
513
588
|
WHERE ino = ?
|
|
514
589
|
""",
|
|
@@ -517,18 +592,535 @@ class Filesystem:
|
|
|
517
592
|
row = await cursor.fetchone()
|
|
518
593
|
|
|
519
594
|
if not row:
|
|
520
|
-
raise
|
|
521
|
-
|
|
522
|
-
|
|
595
|
+
raise ErrnoException(
|
|
596
|
+
code="ENOENT",
|
|
597
|
+
syscall="stat",
|
|
598
|
+
path=normalized_path,
|
|
599
|
+
message="no such file or directory",
|
|
600
|
+
)
|
|
523
601
|
|
|
524
602
|
return Stats(
|
|
525
603
|
ino=row[0],
|
|
526
604
|
mode=row[1],
|
|
527
|
-
nlink=
|
|
528
|
-
uid=row[
|
|
529
|
-
gid=row[
|
|
530
|
-
size=row[
|
|
531
|
-
atime=row[
|
|
532
|
-
mtime=row[
|
|
533
|
-
ctime=row[
|
|
605
|
+
nlink=row[2],
|
|
606
|
+
uid=row[3],
|
|
607
|
+
gid=row[4],
|
|
608
|
+
size=row[5],
|
|
609
|
+
atime=row[6],
|
|
610
|
+
mtime=row[7],
|
|
611
|
+
ctime=row[8],
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
async def mkdir(self, path: str) -> None:
|
|
615
|
+
"""Create a directory (non-recursive)
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
path: Path to the directory to create
|
|
619
|
+
|
|
620
|
+
Example:
|
|
621
|
+
>>> await fs.mkdir('/data/new_dir')
|
|
622
|
+
"""
|
|
623
|
+
normalized_path = self._normalize_path(path)
|
|
624
|
+
|
|
625
|
+
existing = await self._resolve_path(normalized_path)
|
|
626
|
+
if existing is not None:
|
|
627
|
+
raise ErrnoException(
|
|
628
|
+
code="EEXIST",
|
|
629
|
+
syscall="mkdir",
|
|
630
|
+
path=normalized_path,
|
|
631
|
+
message="file already exists",
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
parent = await self._resolve_parent(normalized_path)
|
|
635
|
+
if not parent:
|
|
636
|
+
raise ErrnoException(
|
|
637
|
+
code="ENOENT",
|
|
638
|
+
syscall="mkdir",
|
|
639
|
+
path=normalized_path,
|
|
640
|
+
message="no such file or directory",
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
parent_ino, name = parent
|
|
644
|
+
await assert_inode_is_directory(self._db, parent_ino, "mkdir", normalized_path)
|
|
645
|
+
|
|
646
|
+
dir_ino = await self._create_inode(DEFAULT_DIR_MODE)
|
|
647
|
+
try:
|
|
648
|
+
await self._create_dentry(parent_ino, name, dir_ino)
|
|
649
|
+
except Exception:
|
|
650
|
+
raise ErrnoException(
|
|
651
|
+
code="EEXIST",
|
|
652
|
+
syscall="mkdir",
|
|
653
|
+
path=normalized_path,
|
|
654
|
+
message="file already exists",
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
async def rmdir(self, path: str) -> None:
|
|
658
|
+
"""Remove an empty directory
|
|
659
|
+
|
|
660
|
+
Args:
|
|
661
|
+
path: Path to the directory to remove
|
|
662
|
+
|
|
663
|
+
Example:
|
|
664
|
+
>>> await fs.rmdir('/data/empty_dir')
|
|
665
|
+
"""
|
|
666
|
+
normalized_path = self._normalize_path(path)
|
|
667
|
+
assert_not_root(normalized_path, "rmdir")
|
|
668
|
+
|
|
669
|
+
normalized_path, ino = await self._resolve_path_or_throw(normalized_path, "rmdir")
|
|
670
|
+
|
|
671
|
+
mode = await get_inode_mode_or_throw(self._db, ino, "rmdir", normalized_path)
|
|
672
|
+
assert_not_symlink_mode(mode, "rmdir", normalized_path)
|
|
673
|
+
if (mode & S_IFMT) != S_IFDIR:
|
|
674
|
+
raise ErrnoException(
|
|
675
|
+
code="ENOTDIR",
|
|
676
|
+
syscall="rmdir",
|
|
677
|
+
path=normalized_path,
|
|
678
|
+
message="not a directory",
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
cursor = await self._db.execute(
|
|
682
|
+
"""
|
|
683
|
+
SELECT 1 as one FROM fs_dentry
|
|
684
|
+
WHERE parent_ino = ?
|
|
685
|
+
LIMIT 1
|
|
686
|
+
""",
|
|
687
|
+
(ino,),
|
|
688
|
+
)
|
|
689
|
+
child = await cursor.fetchone()
|
|
690
|
+
if child:
|
|
691
|
+
raise ErrnoException(
|
|
692
|
+
code="ENOTEMPTY",
|
|
693
|
+
syscall="rmdir",
|
|
694
|
+
path=normalized_path,
|
|
695
|
+
message="directory not empty",
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
parent = await self._resolve_parent(normalized_path)
|
|
699
|
+
if not parent:
|
|
700
|
+
raise ErrnoException(
|
|
701
|
+
code="EPERM",
|
|
702
|
+
syscall="rmdir",
|
|
703
|
+
path=normalized_path,
|
|
704
|
+
message="operation not permitted",
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
parent_ino, name = parent
|
|
708
|
+
await self._remove_dentry_and_maybe_inode(parent_ino, name, ino)
|
|
709
|
+
|
|
710
|
+
async def rm(
|
|
711
|
+
self,
|
|
712
|
+
path: str,
|
|
713
|
+
force: bool = False,
|
|
714
|
+
recursive: bool = False,
|
|
715
|
+
) -> None:
|
|
716
|
+
"""Remove a file or directory
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
path: Path to remove
|
|
720
|
+
force: If True, ignore nonexistent files
|
|
721
|
+
recursive: If True, remove directories and their contents recursively
|
|
722
|
+
|
|
723
|
+
Example:
|
|
724
|
+
>>> await fs.rm('/data/file.txt')
|
|
725
|
+
>>> await fs.rm('/data/dir', recursive=True)
|
|
726
|
+
"""
|
|
727
|
+
normalized_path = self._normalize_path(path)
|
|
728
|
+
options = normalize_rm_options({"force": force, "recursive": recursive})
|
|
729
|
+
force = options["force"]
|
|
730
|
+
recursive = options["recursive"]
|
|
731
|
+
assert_not_root(normalized_path, "rm")
|
|
732
|
+
|
|
733
|
+
ino = await self._resolve_path(normalized_path)
|
|
734
|
+
if ino is None:
|
|
735
|
+
throw_enoent_unless_force(normalized_path, "rm", force)
|
|
736
|
+
return
|
|
737
|
+
|
|
738
|
+
mode = await get_inode_mode_or_throw(self._db, ino, "rm", normalized_path)
|
|
739
|
+
assert_not_symlink_mode(mode, "rm", normalized_path)
|
|
740
|
+
|
|
741
|
+
parent = await self._resolve_parent(normalized_path)
|
|
742
|
+
if not parent:
|
|
743
|
+
raise ErrnoException(
|
|
744
|
+
code="EPERM",
|
|
745
|
+
syscall="rm",
|
|
746
|
+
path=normalized_path,
|
|
747
|
+
message="operation not permitted",
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
parent_ino, name = parent
|
|
751
|
+
|
|
752
|
+
if (mode & S_IFMT) == S_IFDIR:
|
|
753
|
+
if not recursive:
|
|
754
|
+
raise ErrnoException(
|
|
755
|
+
code="EISDIR",
|
|
756
|
+
syscall="rm",
|
|
757
|
+
path=normalized_path,
|
|
758
|
+
message="illegal operation on a directory",
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
await self._rm_dir_contents_recursive(ino)
|
|
762
|
+
await self._remove_dentry_and_maybe_inode(parent_ino, name, ino)
|
|
763
|
+
return
|
|
764
|
+
|
|
765
|
+
# Regular file
|
|
766
|
+
await self._remove_dentry_and_maybe_inode(parent_ino, name, ino)
|
|
767
|
+
|
|
768
|
+
async def _rm_dir_contents_recursive(self, dir_ino: int) -> None:
|
|
769
|
+
"""Recursively remove directory contents"""
|
|
770
|
+
cursor = await self._db.execute(
|
|
771
|
+
"""
|
|
772
|
+
SELECT name, ino FROM fs_dentry
|
|
773
|
+
WHERE parent_ino = ?
|
|
774
|
+
ORDER BY name ASC
|
|
775
|
+
""",
|
|
776
|
+
(dir_ino,),
|
|
777
|
+
)
|
|
778
|
+
children = await cursor.fetchall()
|
|
779
|
+
|
|
780
|
+
for name, child_ino in children:
|
|
781
|
+
mode = await self._get_inode_mode(child_ino)
|
|
782
|
+
if mode is None:
|
|
783
|
+
# DB inconsistency; treat as already gone
|
|
784
|
+
continue
|
|
785
|
+
|
|
786
|
+
if (mode & S_IFMT) == S_IFDIR:
|
|
787
|
+
await self._rm_dir_contents_recursive(child_ino)
|
|
788
|
+
await self._remove_dentry_and_maybe_inode(dir_ino, name, child_ino)
|
|
789
|
+
else:
|
|
790
|
+
# Not supported yet (symlinks)
|
|
791
|
+
assert_not_symlink_mode(mode, "rm", "<symlink>")
|
|
792
|
+
await self._remove_dentry_and_maybe_inode(dir_ino, name, child_ino)
|
|
793
|
+
|
|
794
|
+
async def _remove_dentry_and_maybe_inode(self, parent_ino: int, name: str, ino: int) -> None:
|
|
795
|
+
"""Remove directory entry and inode if last link"""
|
|
796
|
+
await self._db.execute(
|
|
797
|
+
"""
|
|
798
|
+
DELETE FROM fs_dentry
|
|
799
|
+
WHERE parent_ino = ? AND name = ?
|
|
800
|
+
""",
|
|
801
|
+
(parent_ino, name),
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
# Decrement link count
|
|
805
|
+
await self._db.execute(
|
|
806
|
+
"UPDATE fs_inode SET nlink = nlink - 1 WHERE ino = ?",
|
|
807
|
+
(ino,),
|
|
534
808
|
)
|
|
809
|
+
|
|
810
|
+
link_count = await self._get_link_count(ino)
|
|
811
|
+
if link_count == 0:
|
|
812
|
+
await self._db.execute("DELETE FROM fs_inode WHERE ino = ?", (ino,))
|
|
813
|
+
await self._db.execute("DELETE FROM fs_data WHERE ino = ?", (ino,))
|
|
814
|
+
|
|
815
|
+
await self._db.commit()
|
|
816
|
+
|
|
817
|
+
async def rename(self, old_path: str, new_path: str) -> None:
|
|
818
|
+
"""Rename (move) a file or directory
|
|
819
|
+
|
|
820
|
+
Args:
|
|
821
|
+
old_path: Current path
|
|
822
|
+
new_path: New path
|
|
823
|
+
|
|
824
|
+
Example:
|
|
825
|
+
>>> await fs.rename('/data/old.txt', '/data/new.txt')
|
|
826
|
+
"""
|
|
827
|
+
old_normalized = self._normalize_path(old_path)
|
|
828
|
+
new_normalized = self._normalize_path(new_path)
|
|
829
|
+
|
|
830
|
+
# No-op
|
|
831
|
+
if old_normalized == new_normalized:
|
|
832
|
+
return
|
|
833
|
+
|
|
834
|
+
assert_not_root(old_normalized, "rename")
|
|
835
|
+
assert_not_root(new_normalized, "rename")
|
|
836
|
+
|
|
837
|
+
old_parent = await self._resolve_parent(old_normalized)
|
|
838
|
+
if not old_parent:
|
|
839
|
+
raise ErrnoException(
|
|
840
|
+
code="EPERM",
|
|
841
|
+
syscall="rename",
|
|
842
|
+
path=old_normalized,
|
|
843
|
+
message="operation not permitted",
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
new_parent = await self._resolve_parent(new_normalized)
|
|
847
|
+
if not new_parent:
|
|
848
|
+
raise ErrnoException(
|
|
849
|
+
code="ENOENT",
|
|
850
|
+
syscall="rename",
|
|
851
|
+
path=new_normalized,
|
|
852
|
+
message="no such file or directory",
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
new_parent_ino, new_name = new_parent
|
|
856
|
+
|
|
857
|
+
# Ensure destination parent exists and is a directory
|
|
858
|
+
await assert_inode_is_directory(self._db, new_parent_ino, "rename", new_normalized)
|
|
859
|
+
|
|
860
|
+
# Begin transaction
|
|
861
|
+
# Note: turso.aio doesn't support explicit BEGIN, but execute should be atomic
|
|
862
|
+
try:
|
|
863
|
+
old_normalized, old_ino = await self._resolve_path_or_throw(old_normalized, "rename")
|
|
864
|
+
old_mode = await get_inode_mode_or_throw(self._db, old_ino, "rename", old_normalized)
|
|
865
|
+
assert_not_symlink_mode(old_mode, "rename", old_normalized)
|
|
866
|
+
old_is_dir = (old_mode & S_IFMT) == S_IFDIR
|
|
867
|
+
|
|
868
|
+
# Prevent renaming a directory into its own subtree (would create cycles)
|
|
869
|
+
if old_is_dir and new_normalized.startswith(old_normalized + "/"):
|
|
870
|
+
raise ErrnoException(
|
|
871
|
+
code="EINVAL",
|
|
872
|
+
syscall="rename",
|
|
873
|
+
path=new_normalized,
|
|
874
|
+
message="invalid argument",
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
new_ino = await self._resolve_path(new_normalized)
|
|
878
|
+
if new_ino is not None:
|
|
879
|
+
new_mode = await get_inode_mode_or_throw(
|
|
880
|
+
self._db, new_ino, "rename", new_normalized
|
|
881
|
+
)
|
|
882
|
+
assert_not_symlink_mode(new_mode, "rename", new_normalized)
|
|
883
|
+
new_is_dir = (new_mode & S_IFMT) == S_IFDIR
|
|
884
|
+
|
|
885
|
+
if new_is_dir and not old_is_dir:
|
|
886
|
+
raise ErrnoException(
|
|
887
|
+
code="EISDIR",
|
|
888
|
+
syscall="rename",
|
|
889
|
+
path=new_normalized,
|
|
890
|
+
message="illegal operation on a directory",
|
|
891
|
+
)
|
|
892
|
+
if not new_is_dir and old_is_dir:
|
|
893
|
+
raise ErrnoException(
|
|
894
|
+
code="ENOTDIR",
|
|
895
|
+
syscall="rename",
|
|
896
|
+
path=new_normalized,
|
|
897
|
+
message="not a directory",
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
# If replacing a directory, it must be empty
|
|
901
|
+
if new_is_dir:
|
|
902
|
+
cursor = await self._db.execute(
|
|
903
|
+
"""
|
|
904
|
+
SELECT 1 as one FROM fs_dentry
|
|
905
|
+
WHERE parent_ino = ?
|
|
906
|
+
LIMIT 1
|
|
907
|
+
""",
|
|
908
|
+
(new_ino,),
|
|
909
|
+
)
|
|
910
|
+
child = await cursor.fetchone()
|
|
911
|
+
if child:
|
|
912
|
+
raise ErrnoException(
|
|
913
|
+
code="ENOTEMPTY",
|
|
914
|
+
syscall="rename",
|
|
915
|
+
path=new_normalized,
|
|
916
|
+
message="directory not empty",
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
# Remove the destination entry (and inode if this was the last link)
|
|
920
|
+
await self._remove_dentry_and_maybe_inode(new_parent_ino, new_name, new_ino)
|
|
921
|
+
|
|
922
|
+
# Move the directory entry
|
|
923
|
+
old_parent_ino, old_name = old_parent
|
|
924
|
+
await self._db.execute(
|
|
925
|
+
"""
|
|
926
|
+
UPDATE fs_dentry
|
|
927
|
+
SET parent_ino = ?, name = ?
|
|
928
|
+
WHERE parent_ino = ? AND name = ?
|
|
929
|
+
""",
|
|
930
|
+
(new_parent_ino, new_name, old_parent_ino, old_name),
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
# Update timestamps
|
|
934
|
+
now = int(time.time())
|
|
935
|
+
await self._db.execute(
|
|
936
|
+
"""
|
|
937
|
+
UPDATE fs_inode
|
|
938
|
+
SET ctime = ?
|
|
939
|
+
WHERE ino = ?
|
|
940
|
+
""",
|
|
941
|
+
(now, old_ino),
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
await self._db.execute(
|
|
945
|
+
"""
|
|
946
|
+
UPDATE fs_inode
|
|
947
|
+
SET mtime = ?, ctime = ?
|
|
948
|
+
WHERE ino = ?
|
|
949
|
+
""",
|
|
950
|
+
(now, now, old_parent_ino),
|
|
951
|
+
)
|
|
952
|
+
if new_parent_ino != old_parent_ino:
|
|
953
|
+
await self._db.execute(
|
|
954
|
+
"""
|
|
955
|
+
UPDATE fs_inode
|
|
956
|
+
SET mtime = ?, ctime = ?
|
|
957
|
+
WHERE ino = ?
|
|
958
|
+
""",
|
|
959
|
+
(now, now, new_parent_ino),
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
await self._db.commit()
|
|
963
|
+
except Exception:
|
|
964
|
+
# turso.aio doesn't have explicit rollback, changes are rolled back automatically
|
|
965
|
+
raise
|
|
966
|
+
|
|
967
|
+
async def copy_file(self, src: str, dest: str) -> None:
|
|
968
|
+
"""Copy a file. Overwrites destination if it exists.
|
|
969
|
+
|
|
970
|
+
Args:
|
|
971
|
+
src: Source file path
|
|
972
|
+
dest: Destination file path
|
|
973
|
+
|
|
974
|
+
Example:
|
|
975
|
+
>>> await fs.copy_file('/data/src.txt', '/data/dest.txt')
|
|
976
|
+
"""
|
|
977
|
+
src_normalized = self._normalize_path(src)
|
|
978
|
+
dest_normalized = self._normalize_path(dest)
|
|
979
|
+
|
|
980
|
+
if src_normalized == dest_normalized:
|
|
981
|
+
raise ErrnoException(
|
|
982
|
+
code="EINVAL",
|
|
983
|
+
syscall="copyfile",
|
|
984
|
+
path=dest_normalized,
|
|
985
|
+
message="invalid argument",
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
# Resolve and validate source
|
|
989
|
+
src_normalized, src_ino = await self._resolve_path_or_throw(src_normalized, "copyfile")
|
|
990
|
+
await assert_readable_existing_inode(self._db, src_ino, "copyfile", src_normalized)
|
|
991
|
+
|
|
992
|
+
cursor = await self._db.execute(
|
|
993
|
+
"""
|
|
994
|
+
SELECT mode, uid, gid, size FROM fs_inode WHERE ino = ?
|
|
995
|
+
""",
|
|
996
|
+
(src_ino,),
|
|
997
|
+
)
|
|
998
|
+
src_row = await cursor.fetchone()
|
|
999
|
+
if not src_row:
|
|
1000
|
+
raise ErrnoException(
|
|
1001
|
+
code="ENOENT",
|
|
1002
|
+
syscall="copyfile",
|
|
1003
|
+
path=src_normalized,
|
|
1004
|
+
message="no such file or directory",
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
src_mode, src_uid, src_gid, src_size = src_row
|
|
1008
|
+
|
|
1009
|
+
# Destination parent must exist and be a directory
|
|
1010
|
+
dest_parent = await self._resolve_parent(dest_normalized)
|
|
1011
|
+
if not dest_parent:
|
|
1012
|
+
raise ErrnoException(
|
|
1013
|
+
code="ENOENT",
|
|
1014
|
+
syscall="copyfile",
|
|
1015
|
+
path=dest_normalized,
|
|
1016
|
+
message="no such file or directory",
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
dest_parent_ino, dest_name = dest_parent
|
|
1020
|
+
await assert_inode_is_directory(self._db, dest_parent_ino, "copyfile", dest_normalized)
|
|
1021
|
+
|
|
1022
|
+
try:
|
|
1023
|
+
now = int(time.time())
|
|
1024
|
+
|
|
1025
|
+
# If destination exists, it must be a file (overwrite semantics)
|
|
1026
|
+
dest_ino = await self._resolve_path(dest_normalized)
|
|
1027
|
+
if dest_ino is not None:
|
|
1028
|
+
dest_mode = await get_inode_mode_or_throw(
|
|
1029
|
+
self._db, dest_ino, "copyfile", dest_normalized
|
|
1030
|
+
)
|
|
1031
|
+
assert_not_symlink_mode(dest_mode, "copyfile", dest_normalized)
|
|
1032
|
+
if (dest_mode & S_IFMT) == S_IFDIR:
|
|
1033
|
+
raise ErrnoException(
|
|
1034
|
+
code="EISDIR",
|
|
1035
|
+
syscall="copyfile",
|
|
1036
|
+
path=dest_normalized,
|
|
1037
|
+
message="illegal operation on a directory",
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
# Replace destination contents
|
|
1041
|
+
await self._db.execute("DELETE FROM fs_data WHERE ino = ?", (dest_ino,))
|
|
1042
|
+
await self._db.commit()
|
|
1043
|
+
|
|
1044
|
+
# Copy data chunks
|
|
1045
|
+
cursor = await self._db.execute(
|
|
1046
|
+
"""
|
|
1047
|
+
SELECT chunk_index, data FROM fs_data
|
|
1048
|
+
WHERE ino = ?
|
|
1049
|
+
ORDER BY chunk_index ASC
|
|
1050
|
+
""",
|
|
1051
|
+
(src_ino,),
|
|
1052
|
+
)
|
|
1053
|
+
src_chunks = await cursor.fetchall()
|
|
1054
|
+
for chunk_index, data in src_chunks:
|
|
1055
|
+
await self._db.execute(
|
|
1056
|
+
"""
|
|
1057
|
+
INSERT INTO fs_data (ino, chunk_index, data)
|
|
1058
|
+
VALUES (?, ?, ?)
|
|
1059
|
+
""",
|
|
1060
|
+
(dest_ino, chunk_index, data),
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
await self._db.execute(
|
|
1064
|
+
"""
|
|
1065
|
+
UPDATE fs_inode
|
|
1066
|
+
SET mode = ?, uid = ?, gid = ?, size = ?, mtime = ?, ctime = ?
|
|
1067
|
+
WHERE ino = ?
|
|
1068
|
+
""",
|
|
1069
|
+
(src_mode, src_uid, src_gid, src_size, now, now, dest_ino),
|
|
1070
|
+
)
|
|
1071
|
+
else:
|
|
1072
|
+
# Create new destination inode + dentry
|
|
1073
|
+
dest_ino_created = await self._create_inode(src_mode, src_uid, src_gid)
|
|
1074
|
+
await self._create_dentry(dest_parent_ino, dest_name, dest_ino_created)
|
|
1075
|
+
|
|
1076
|
+
# Copy data chunks
|
|
1077
|
+
cursor = await self._db.execute(
|
|
1078
|
+
"""
|
|
1079
|
+
SELECT chunk_index, data FROM fs_data
|
|
1080
|
+
WHERE ino = ?
|
|
1081
|
+
ORDER BY chunk_index ASC
|
|
1082
|
+
""",
|
|
1083
|
+
(src_ino,),
|
|
1084
|
+
)
|
|
1085
|
+
src_chunks = await cursor.fetchall()
|
|
1086
|
+
for chunk_index, data in src_chunks:
|
|
1087
|
+
await self._db.execute(
|
|
1088
|
+
"""
|
|
1089
|
+
INSERT INTO fs_data (ino, chunk_index, data)
|
|
1090
|
+
VALUES (?, ?, ?)
|
|
1091
|
+
""",
|
|
1092
|
+
(dest_ino_created, chunk_index, data),
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
await self._db.execute(
|
|
1096
|
+
"""
|
|
1097
|
+
UPDATE fs_inode
|
|
1098
|
+
SET size = ?, mtime = ?, ctime = ?
|
|
1099
|
+
WHERE ino = ?
|
|
1100
|
+
""",
|
|
1101
|
+
(src_size, now, now, dest_ino_created),
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
await self._db.commit()
|
|
1105
|
+
except Exception:
|
|
1106
|
+
raise
|
|
1107
|
+
|
|
1108
|
+
async def access(self, path: str) -> None:
|
|
1109
|
+
"""Test a user's permissions for the file or directory.
|
|
1110
|
+
Currently supports existence checks only (F_OK semantics).
|
|
1111
|
+
|
|
1112
|
+
Args:
|
|
1113
|
+
path: Path to check
|
|
1114
|
+
|
|
1115
|
+
Example:
|
|
1116
|
+
>>> await fs.access('/data/config.json')
|
|
1117
|
+
"""
|
|
1118
|
+
normalized_path = self._normalize_path(path)
|
|
1119
|
+
ino = await self._resolve_path(normalized_path)
|
|
1120
|
+
if ino is None:
|
|
1121
|
+
raise ErrnoException(
|
|
1122
|
+
code="ENOENT",
|
|
1123
|
+
syscall="access",
|
|
1124
|
+
path=normalized_path,
|
|
1125
|
+
message="no such file or directory",
|
|
1126
|
+
)
|
agentfs_sdk/guards.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Guard functions for filesystem operations validation"""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from turso.aio import Connection
|
|
6
|
+
|
|
7
|
+
from .constants import S_IFDIR, S_IFLNK, S_IFMT
|
|
8
|
+
from .errors import ErrnoException, FsSyscall
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _get_inode_mode(db: Connection, ino: int) -> Optional[int]:
|
|
12
|
+
"""Get mode for an inode"""
|
|
13
|
+
cursor = await db.execute("SELECT mode FROM fs_inode WHERE ino = ?", (ino,))
|
|
14
|
+
row = await cursor.fetchone()
|
|
15
|
+
return row[0] if row else None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_dir_mode(mode: int) -> bool:
|
|
19
|
+
"""Check if mode represents a directory"""
|
|
20
|
+
return (mode & S_IFMT) == S_IFDIR
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def get_inode_mode_or_throw(
|
|
24
|
+
db: Connection,
|
|
25
|
+
ino: int,
|
|
26
|
+
syscall: FsSyscall,
|
|
27
|
+
path: str,
|
|
28
|
+
) -> int:
|
|
29
|
+
"""Get inode mode or throw ENOENT if not found"""
|
|
30
|
+
mode = await _get_inode_mode(db, ino)
|
|
31
|
+
if mode is None:
|
|
32
|
+
raise ErrnoException(
|
|
33
|
+
code="ENOENT",
|
|
34
|
+
syscall=syscall,
|
|
35
|
+
path=path,
|
|
36
|
+
message="no such file or directory",
|
|
37
|
+
)
|
|
38
|
+
return mode
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def assert_not_root(path: str, syscall: FsSyscall) -> None:
|
|
42
|
+
"""Assert that path is not root directory"""
|
|
43
|
+
if path == "/":
|
|
44
|
+
raise ErrnoException(
|
|
45
|
+
code="EPERM",
|
|
46
|
+
syscall=syscall,
|
|
47
|
+
path=path,
|
|
48
|
+
message="operation not permitted on root directory",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_rm_options(options: Optional[Dict[str, Any]]) -> Dict[str, bool]:
|
|
53
|
+
"""Normalize rm options to ensure force and recursive are booleans"""
|
|
54
|
+
return {
|
|
55
|
+
"force": options.get("force", False) if options else False,
|
|
56
|
+
"recursive": options.get("recursive", False) if options else False,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def throw_enoent_unless_force(path: str, syscall: FsSyscall, force: bool) -> None:
|
|
61
|
+
"""Throw ENOENT unless force flag is set"""
|
|
62
|
+
if force:
|
|
63
|
+
return
|
|
64
|
+
raise ErrnoException(
|
|
65
|
+
code="ENOENT",
|
|
66
|
+
syscall=syscall,
|
|
67
|
+
path=path,
|
|
68
|
+
message="no such file or directory",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def assert_not_symlink_mode(mode: int, syscall: FsSyscall, path: str) -> None:
|
|
73
|
+
"""Assert that mode does not represent a symlink"""
|
|
74
|
+
if (mode & S_IFMT) == S_IFLNK:
|
|
75
|
+
raise ErrnoException(
|
|
76
|
+
code="ENOSYS",
|
|
77
|
+
syscall=syscall,
|
|
78
|
+
path=path,
|
|
79
|
+
message="symbolic links not supported yet",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _assert_existing_non_dir_non_symlink_inode(
|
|
84
|
+
db: Connection,
|
|
85
|
+
ino: int,
|
|
86
|
+
syscall: FsSyscall,
|
|
87
|
+
full_path_for_error: str,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Assert inode exists and is neither directory nor symlink"""
|
|
90
|
+
mode = await _get_inode_mode(db, ino)
|
|
91
|
+
if mode is None:
|
|
92
|
+
raise ErrnoException(
|
|
93
|
+
code="ENOENT",
|
|
94
|
+
syscall=syscall,
|
|
95
|
+
path=full_path_for_error,
|
|
96
|
+
message="no such file or directory",
|
|
97
|
+
)
|
|
98
|
+
if _is_dir_mode(mode):
|
|
99
|
+
raise ErrnoException(
|
|
100
|
+
code="EISDIR",
|
|
101
|
+
syscall=syscall,
|
|
102
|
+
path=full_path_for_error,
|
|
103
|
+
message="illegal operation on a directory",
|
|
104
|
+
)
|
|
105
|
+
assert_not_symlink_mode(mode, syscall, full_path_for_error)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def assert_inode_is_directory(
|
|
109
|
+
db: Connection,
|
|
110
|
+
ino: int,
|
|
111
|
+
syscall: FsSyscall,
|
|
112
|
+
full_path_for_error: str,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Assert that inode is a directory"""
|
|
115
|
+
mode = await _get_inode_mode(db, ino)
|
|
116
|
+
if mode is None:
|
|
117
|
+
raise ErrnoException(
|
|
118
|
+
code="ENOENT",
|
|
119
|
+
syscall=syscall,
|
|
120
|
+
path=full_path_for_error,
|
|
121
|
+
message="no such file or directory",
|
|
122
|
+
)
|
|
123
|
+
if not _is_dir_mode(mode):
|
|
124
|
+
raise ErrnoException(
|
|
125
|
+
code="ENOTDIR",
|
|
126
|
+
syscall=syscall,
|
|
127
|
+
path=full_path_for_error,
|
|
128
|
+
message="not a directory",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def assert_writable_existing_inode(
|
|
133
|
+
db: Connection,
|
|
134
|
+
ino: int,
|
|
135
|
+
syscall: FsSyscall,
|
|
136
|
+
full_path_for_error: str,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Assert inode is writable (exists and is not directory/symlink)"""
|
|
139
|
+
await _assert_existing_non_dir_non_symlink_inode(db, ino, syscall, full_path_for_error)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def assert_readable_existing_inode(
|
|
143
|
+
db: Connection,
|
|
144
|
+
ino: int,
|
|
145
|
+
syscall: FsSyscall,
|
|
146
|
+
full_path_for_error: str,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Assert inode is readable (exists and is not directory/symlink)"""
|
|
149
|
+
await _assert_existing_non_dir_non_symlink_inode(db, ino, syscall, full_path_for_error)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def assert_readdir_target_inode(
|
|
153
|
+
db: Connection,
|
|
154
|
+
ino: int,
|
|
155
|
+
full_path_for_error: str,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Assert inode is a valid readdir target (directory, not symlink)"""
|
|
158
|
+
syscall: FsSyscall = "scandir"
|
|
159
|
+
mode = await _get_inode_mode(db, ino)
|
|
160
|
+
if mode is None:
|
|
161
|
+
raise ErrnoException(
|
|
162
|
+
code="ENOENT",
|
|
163
|
+
syscall=syscall,
|
|
164
|
+
path=full_path_for_error,
|
|
165
|
+
message="no such file or directory",
|
|
166
|
+
)
|
|
167
|
+
assert_not_symlink_mode(mode, syscall, full_path_for_error)
|
|
168
|
+
if not _is_dir_mode(mode):
|
|
169
|
+
raise ErrnoException(
|
|
170
|
+
code="ENOTDIR",
|
|
171
|
+
syscall=syscall,
|
|
172
|
+
path=full_path_for_error,
|
|
173
|
+
message="not a directory",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def assert_unlink_target_inode(
|
|
178
|
+
db: Connection,
|
|
179
|
+
ino: int,
|
|
180
|
+
full_path_for_error: str,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Assert inode is a valid unlink target (file, not directory/symlink)"""
|
|
183
|
+
syscall: FsSyscall = "unlink"
|
|
184
|
+
mode = await _get_inode_mode(db, ino)
|
|
185
|
+
if mode is None:
|
|
186
|
+
raise ErrnoException(
|
|
187
|
+
code="ENOENT",
|
|
188
|
+
syscall=syscall,
|
|
189
|
+
path=full_path_for_error,
|
|
190
|
+
message="no such file or directory",
|
|
191
|
+
)
|
|
192
|
+
if _is_dir_mode(mode):
|
|
193
|
+
raise ErrnoException(
|
|
194
|
+
code="EISDIR",
|
|
195
|
+
syscall=syscall,
|
|
196
|
+
path=full_path_for_error,
|
|
197
|
+
message="illegal operation on a directory",
|
|
198
|
+
)
|
|
199
|
+
assert_not_symlink_mode(mode, syscall, full_path_for_error)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentfs-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: AgentFS Python SDK - A filesystem and key-value store for AI agents
|
|
5
5
|
Author: Turso
|
|
6
6
|
License: MIT
|
|
@@ -16,9 +16,11 @@ Classifier: Operating System :: POSIX :: Linux
|
|
|
16
16
|
Classifier: Operating System :: Microsoft :: Windows
|
|
17
17
|
Classifier: Operating System :: MacOS
|
|
18
18
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
-
Requires-Python: >=3.
|
|
23
|
+
Requires-Python: >=3.10
|
|
22
24
|
Description-Content-Type: text/markdown
|
|
23
25
|
Requires-Dist: pyturso==0.4.0rc17
|
|
24
26
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
agentfs_sdk/__init__.py,sha256=Z5Sgyy1FiWoRnX6QIZn5xCNpEXbVdAYKDdJNnNUbl_M,655
|
|
2
|
+
agentfs_sdk/agentfs.py,sha256=WW7-foT9Vqeq8ZVAdixxHCQHq74zkaYhPM0xVpZL_WM,4071
|
|
3
|
+
agentfs_sdk/constants.py,sha256=wKBX0PwtgGo1lOz61XHouje9nCFEok2AooHWVt7QJrI,367
|
|
4
|
+
agentfs_sdk/errors.py,sha256=P37aeKn8MJ6cj5ZLjUFd8vMBj5m4cUrqmc-4pvK-d3g,1728
|
|
5
|
+
agentfs_sdk/filesystem.py,sha256=TVFpftptXLWWzQjzuq2L3Bbi9sIAjDGwspcBdxVyGNU,37190
|
|
6
|
+
agentfs_sdk/guards.py,sha256=Hea6FnJMn8vHY5-EwCgX4uZBA4TNT4t90br_Q9VIYs0,5804
|
|
7
|
+
agentfs_sdk/kvstore.py,sha256=3p3UeuhcJUDId6bCYMAUZ1jIZqyhMMaE0BjlnV9cjAI,4048
|
|
8
|
+
agentfs_sdk/toolcalls.py,sha256=OEEywUiPO8dPs263Xiu1CfeaQzYq-E6W3biu3nnBQoU,13041
|
|
9
|
+
agentfs_sdk-0.4.0.dist-info/METADATA,sha256=Yw5H--FB5fccugtZRFA7S6dHgIOlHunuE3OwrS1NU_Y,5315
|
|
10
|
+
agentfs_sdk-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
11
|
+
agentfs_sdk-0.4.0.dist-info/top_level.txt,sha256=yAslKFmXq_LQAnRhDcE5uj-KPuHdfyUGB4EzlQLmsuI,12
|
|
12
|
+
agentfs_sdk-0.4.0.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
agentfs_sdk/__init__.py,sha256=AGIv38APCzx9rIHQ4plyflvA4TfkL_0mpHNfyEfGgJ0,444
|
|
2
|
-
agentfs_sdk/agentfs.py,sha256=WW7-foT9Vqeq8ZVAdixxHCQHq74zkaYhPM0xVpZL_WM,4071
|
|
3
|
-
agentfs_sdk/filesystem.py,sha256=Gr80Jy36kILb-Nvd2SBFmVmsDdCQzdN_aGCneHMlros,16462
|
|
4
|
-
agentfs_sdk/kvstore.py,sha256=3p3UeuhcJUDId6bCYMAUZ1jIZqyhMMaE0BjlnV9cjAI,4048
|
|
5
|
-
agentfs_sdk/toolcalls.py,sha256=OEEywUiPO8dPs263Xiu1CfeaQzYq-E6W3biu3nnBQoU,13041
|
|
6
|
-
agentfs_sdk-0.3.1.dist-info/METADATA,sha256=Wy-HcIw_8fHjJPACPvbPQb5ElKmKQVxvtb5sm1SpjYI,5213
|
|
7
|
-
agentfs_sdk-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
-
agentfs_sdk-0.3.1.dist-info/top_level.txt,sha256=yAslKFmXq_LQAnRhDcE5uj-KPuHdfyUGB4EzlQLmsuI,12
|
|
9
|
-
agentfs_sdk-0.3.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|