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 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 .filesystem import Filesystem, Stats
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.3.1"
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
  ]
@@ -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
- # File types for mode field
10
- S_IFMT = 0o170000 # File type mask
11
- S_IFREG = 0o100000 # Regular file
12
- S_IFDIR = 0o040000 # Directory
13
- S_IFLNK = 0o120000 # Symbolic link
14
-
15
- # Default permissions
16
- DEFAULT_FILE_MODE = S_IFREG | 0o644 # Regular file, rw-r--r--
17
- DEFAULT_DIR_MODE = S_IFDIR | 0o755 # Directory, rwxr-xr-x
18
-
19
- DEFAULT_CHUNK_SIZE = 4096
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 COUNT(*) FROM fs_dentry WHERE ino = ?", (ino,))
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 write_file(self, path: str, content: Union[str, bytes]) -> None:
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(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(path)
377
+ parent = await self._resolve_parent(normalized_path)
330
378
  if not parent:
331
- raise FileNotFoundError(f"ENOENT: parent directory does not exist: {path}")
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(self, ino: int, content: Union[str, bytes]) -> None:
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("utf-8") if isinstance(content, str) else content
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._resolve_path(path)
392
- if ino is None:
393
- raise FileNotFoundError(f"ENOENT: no such file or directory, open '{path}'")
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._resolve_path(path)
436
- if ino is None:
437
- raise FileNotFoundError(f"ENOENT: no such file or directory, scandir '{path}'")
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 delete_file(self, path: str) -> None:
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.delete_file('/data/temp.txt')
517
+ >>> await fs.unlink('/data/temp.txt')
460
518
  """
461
- ino = await self._resolve_path(path)
462
- if ino is None:
463
- raise FileNotFoundError(f"ENOENT: no such file or directory, unlink '{path}'")
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
- parent = await self._resolve_parent(path)
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._resolve_path(path)
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 ValueError(f"Inode not found: {ino}")
521
-
522
- nlink = await self._get_link_count(ino)
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=nlink,
528
- uid=row[2],
529
- gid=row[3],
530
- size=row[4],
531
- atime=row[5],
532
- mtime=row[6],
533
- ctime=row[7],
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.1
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.12
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,,