agentfs-sdk 0.4.0__py3-none-any.whl → 0.4.0rc2__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,12 +4,11 @@ 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 .errors import ErrnoException, FsErrorCode, FsSyscall
8
- from .filesystem import S_IFDIR, S_IFLNK, S_IFMT, S_IFREG, Filesystem, Stats
7
+ from .filesystem import Filesystem, Stats
9
8
  from .kvstore import KvStore
10
9
  from .toolcalls import ToolCall, ToolCalls, ToolCallStats
11
10
 
12
- __version__ = "0.4.0"
11
+ __version__ = "0.4.0-pre.2"
13
12
 
14
13
  __all__ = [
15
14
  "AgentFS",
@@ -17,14 +16,7 @@ __all__ = [
17
16
  "KvStore",
18
17
  "Filesystem",
19
18
  "Stats",
20
- "S_IFDIR",
21
- "S_IFLNK",
22
- "S_IFMT",
23
- "S_IFREG",
24
19
  "ToolCalls",
25
20
  "ToolCall",
26
21
  "ToolCallStats",
27
- "ErrnoException",
28
- "FsErrorCode",
29
- "FsSyscall",
30
22
  ]
agentfs_sdk/filesystem.py CHANGED
@@ -6,31 +6,17 @@ from typing import List, Optional, Union
6
6
 
7
7
  from turso.aio import Connection
8
8
 
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"]
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
34
20
 
35
21
 
36
22
  @dataclass
@@ -115,7 +101,6 @@ class Filesystem:
115
101
  CREATE TABLE IF NOT EXISTS fs_inode (
116
102
  ino INTEGER PRIMARY KEY AUTOINCREMENT,
117
103
  mode INTEGER NOT NULL,
118
- nlink INTEGER NOT NULL DEFAULT 0,
119
104
  uid INTEGER NOT NULL DEFAULT 0,
120
105
  gid INTEGER NOT NULL DEFAULT 0,
121
106
  size INTEGER NOT NULL DEFAULT 0,
@@ -176,8 +161,8 @@ class Filesystem:
176
161
  now = int(time.time())
177
162
  await self._db.execute(
178
163
  """
179
- INSERT INTO fs_inode (ino, mode, nlink, uid, gid, size, atime, mtime, ctime)
180
- VALUES (?, ?, 1, 0, 0, 0, ?, ?, ?)
164
+ INSERT INTO fs_inode (ino, mode, uid, gid, size, atime, mtime, ctime)
165
+ VALUES (?, ?, 0, 0, 0, ?, ?, ?)
181
166
  """,
182
167
  (self._root_ino, DEFAULT_DIR_MODE, now, now, now),
183
168
  )
@@ -281,11 +266,6 @@ class Filesystem:
281
266
  """,
282
267
  (name, parent_ino, ino),
283
268
  )
284
- # Increment link count
285
- await self._db.execute(
286
- "UPDATE fs_inode SET nlink = nlink + 1 WHERE ino = ?",
287
- (ino,),
288
- )
289
269
  await self._db.commit()
290
270
 
291
271
  async def _ensure_parent_dirs(self, path: str) -> None:
@@ -321,41 +301,16 @@ class Filesystem:
321
301
 
322
302
  async def _get_link_count(self, ino: int) -> int:
323
303
  """Get link count for an inode"""
324
- cursor = await self._db.execute("SELECT nlink FROM fs_inode WHERE ino = ?", (ino,))
304
+ cursor = await self._db.execute("SELECT COUNT(*) FROM fs_dentry WHERE ino = ?", (ino,))
325
305
  result = await cursor.fetchone()
326
306
  return result[0] if result else 0
327
307
 
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:
308
+ async def write_file(self, path: str, content: Union[str, bytes]) -> None:
353
309
  """Write content to a file
354
310
 
355
311
  Args:
356
312
  path: Path to the file
357
313
  content: Content to write (string or bytes)
358
- encoding: Text encoding (default: 'utf-8')
359
314
 
360
315
  Example:
361
316
  >>> await fs.write_file('/data/config.json', '{"key": "value"}')
@@ -363,31 +318,20 @@ class Filesystem:
363
318
  # Ensure parent directories exist
364
319
  await self._ensure_parent_dirs(path)
365
320
 
366
- normalized_path = self._normalize_path(path)
367
321
  # Check if file already exists
368
- ino = await self._resolve_path(normalized_path)
322
+ ino = await self._resolve_path(path)
369
323
 
370
324
  if ino is not None:
371
- # Validate existing inode
372
- await assert_writable_existing_inode(self._db, ino, "open", normalized_path)
373
325
  # Update existing file
374
- await self._update_file_content(ino, content, encoding)
326
+ await self._update_file_content(ino, content)
375
327
  else:
376
328
  # Create new file
377
- parent = await self._resolve_parent(normalized_path)
329
+ parent = await self._resolve_parent(path)
378
330
  if not parent:
379
- raise ErrnoException(
380
- code="ENOENT",
381
- syscall="open",
382
- path=normalized_path,
383
- message="no such file or directory",
384
- )
331
+ raise FileNotFoundError(f"ENOENT: parent directory does not exist: {path}")
385
332
 
386
333
  parent_ino, name = parent
387
334
 
388
- # Ensure parent is a directory
389
- await assert_inode_is_directory(self._db, parent_ino, "open", normalized_path)
390
-
391
335
  # Create inode
392
336
  file_ino = await self._create_inode(DEFAULT_FILE_MODE)
393
337
 
@@ -395,13 +339,11 @@ class Filesystem:
395
339
  await self._create_dentry(parent_ino, name, file_ino)
396
340
 
397
341
  # Write content
398
- await self._update_file_content(file_ino, content, encoding)
342
+ await self._update_file_content(file_ino, content)
399
343
 
400
- async def _update_file_content(
401
- self, ino: int, content: Union[str, bytes], encoding: str = "utf-8"
402
- ) -> None:
344
+ async def _update_file_content(self, ino: int, content: Union[str, bytes]) -> None:
403
345
  """Update file content"""
404
- buffer = content.encode(encoding) if isinstance(content, str) else content
346
+ buffer = content.encode("utf-8") if isinstance(content, str) else content
405
347
  now = int(time.time())
406
348
 
407
349
  # Delete existing data chunks
@@ -446,9 +388,9 @@ class Filesystem:
446
388
  >>> content = await fs.read_file('/data/config.json')
447
389
  >>> data = await fs.read_file('/data/image.png', encoding=None)
448
390
  """
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)
391
+ ino = await self._resolve_path(path)
392
+ if ino is None:
393
+ raise FileNotFoundError(f"ENOENT: no such file or directory, open '{path}'")
452
394
 
453
395
  # Get all data chunks
454
396
  cursor = await self._db.execute(
@@ -490,9 +432,9 @@ class Filesystem:
490
432
  >>> for entry in entries:
491
433
  >>> print(entry)
492
434
  """
493
- normalized_path, ino = await self._resolve_path_or_throw(path, "scandir")
494
-
495
- await assert_readdir_target_inode(self._db, ino, normalized_path)
435
+ ino = await self._resolve_path(path)
436
+ if ino is None:
437
+ raise FileNotFoundError(f"ENOENT: no such file or directory, scandir '{path}'")
496
438
 
497
439
  # Get all directory entries
498
440
  cursor = await self._db.execute(
@@ -507,24 +449,23 @@ class Filesystem:
507
449
 
508
450
  return [row[0] for row in rows]
509
451
 
510
- async def unlink(self, path: str) -> None:
511
- """Delete a file (unlink)
452
+ async def delete_file(self, path: str) -> None:
453
+ """Delete a file
512
454
 
513
455
  Args:
514
456
  path: Path to the file
515
457
 
516
458
  Example:
517
- >>> await fs.unlink('/data/temp.txt')
459
+ >>> await fs.delete_file('/data/temp.txt')
518
460
  """
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")
461
+ ino = await self._resolve_path(path)
462
+ if ino is None:
463
+ raise FileNotFoundError(f"ENOENT: no such file or directory, unlink '{path}'")
522
464
 
523
- await assert_unlink_target_inode(self._db, ino, normalized_path)
465
+ parent = await self._resolve_parent(path)
466
+ if not parent:
467
+ raise ValueError("Cannot delete root directory")
524
468
 
525
- parent = await self._resolve_parent(normalized_path)
526
- # parent is guaranteed to exist here since normalized_path != '/'
527
- assert parent is not None
528
469
  parent_ino, name = parent
529
470
 
530
471
  # Delete the directory entry
@@ -536,12 +477,6 @@ class Filesystem:
536
477
  (parent_ino, name),
537
478
  )
538
479
 
539
- # Decrement link count
540
- await self._db.execute(
541
- "UPDATE fs_inode SET nlink = nlink - 1 WHERE ino = ?",
542
- (ino,),
543
- )
544
-
545
480
  # Check if this was the last link to the inode
546
481
  link_count = await self._get_link_count(ino)
547
482
  if link_count == 0:
@@ -553,18 +488,6 @@ class Filesystem:
553
488
 
554
489
  await self._db.commit()
555
490
 
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
-
568
491
  async def stat(self, path: str) -> Stats:
569
492
  """Get file/directory statistics
570
493
 
@@ -579,11 +502,13 @@ class Filesystem:
579
502
  >>> print(f"Size: {stats.size} bytes")
580
503
  >>> print(f"Is file: {stats.is_file()}")
581
504
  """
582
- normalized_path, ino = await self._resolve_path_or_throw(path, "stat")
505
+ ino = await self._resolve_path(path)
506
+ if ino is None:
507
+ raise FileNotFoundError(f"ENOENT: no such file or directory, stat '{path}'")
583
508
 
584
509
  cursor = await self._db.execute(
585
510
  """
586
- SELECT ino, mode, nlink, uid, gid, size, atime, mtime, ctime
511
+ SELECT ino, mode, uid, gid, size, atime, mtime, ctime
587
512
  FROM fs_inode
588
513
  WHERE ino = ?
589
514
  """,
@@ -592,535 +517,18 @@ class Filesystem:
592
517
  row = await cursor.fetchone()
593
518
 
594
519
  if not row:
595
- raise ErrnoException(
596
- code="ENOENT",
597
- syscall="stat",
598
- path=normalized_path,
599
- message="no such file or directory",
600
- )
520
+ raise ValueError(f"Inode not found: {ino}")
521
+
522
+ nlink = await self._get_link_count(ino)
601
523
 
602
524
  return Stats(
603
525
  ino=row[0],
604
526
  mode=row[1],
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,),
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],
808
534
  )
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
- )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentfs-sdk
3
- Version: 0.4.0
3
+ Version: 0.4.0rc2
4
4
  Summary: AgentFS Python SDK - A filesystem and key-value store for AI agents
5
5
  Author: Turso
6
6
  License: MIT
@@ -16,11 +16,9 @@ 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
21
19
  Classifier: Programming Language :: Python :: 3.12
22
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
- Requires-Python: >=3.10
21
+ Requires-Python: >=3.12
24
22
  Description-Content-Type: text/markdown
25
23
  Requires-Dist: pyturso==0.4.0rc17
26
24
 
@@ -0,0 +1,9 @@
1
+ agentfs_sdk/__init__.py,sha256=K7crr3djIXWS4sXWzVMZH7HS4R5jYVEQqs_n72mderQ,450
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.4.0rc2.dist-info/METADATA,sha256=FaLTSL10V97gjBZVQ6jN-p4z45fAa4ROwoNvKxOUxoA,5216
7
+ agentfs_sdk-0.4.0rc2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ agentfs_sdk-0.4.0rc2.dist-info/top_level.txt,sha256=yAslKFmXq_LQAnRhDcE5uj-KPuHdfyUGB4EzlQLmsuI,12
9
+ agentfs_sdk-0.4.0rc2.dist-info/RECORD,,
agentfs_sdk/constants.py DELETED
@@ -1,13 +0,0 @@
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 DELETED
@@ -1,60 +0,0 @@
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/guards.py DELETED
@@ -1,199 +0,0 @@
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,12 +0,0 @@
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,,