sandfs 0.1.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.
sandfs/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """sandfs package: virtual filesystem sandbox with a mini shell."""
2
+
3
+ from .vfs import VirtualFileSystem
4
+ from .shell import SandboxShell, CommandResult
5
+ from .pyexec import PythonExecutor, PythonExecutionResult
6
+ from .providers import ContentProvider, DirectoryProvider, NodeContext, ProvidedNode
7
+ from .policies import NodePolicy, VisibilityView
8
+ from .hooks import WriteEvent, WriteHook
9
+ from .integrations import PathEvent, PathHook
10
+ from .adapters import MemoryStorageAdapter, StorageAdapter
11
+
12
+ __all__ = [
13
+ "VirtualFileSystem",
14
+ "SandboxShell",
15
+ "CommandResult",
16
+ "PythonExecutor",
17
+ "PythonExecutionResult",
18
+ "ContentProvider",
19
+ "DirectoryProvider",
20
+ "NodeContext",
21
+ "ProvidedNode",
22
+ "NodePolicy",
23
+ "VisibilityView",
24
+ "WriteEvent",
25
+ "WriteHook",
26
+ "PathEvent",
27
+ "PathHook",
28
+ "StorageAdapter",
29
+ "MemoryStorageAdapter",
30
+ ]
sandfs/adapters.py ADDED
@@ -0,0 +1,58 @@
1
+ """Storage adapter interfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Dict, Mapping, Optional
7
+
8
+
9
+ @dataclass
10
+ class StorageEntry:
11
+ content: str
12
+ version: int = 0
13
+
14
+
15
+ class StorageAdapter:
16
+ def read(self, path: str) -> StorageEntry:
17
+ raise NotImplementedError
18
+
19
+ def write(self, path: str, content: str, *, version: int) -> StorageEntry:
20
+ raise NotImplementedError
21
+
22
+ def list(self) -> Dict[str, StorageEntry]:
23
+ raise NotImplementedError
24
+
25
+ def delete(self, path: str) -> None:
26
+ raise NotImplementedError
27
+
28
+
29
+ @dataclass
30
+ class MemoryStorageAdapter(StorageAdapter):
31
+ initial: Mapping[str, str] = field(default_factory=dict)
32
+
33
+ def __post_init__(self) -> None:
34
+ self._entries: Dict[str, StorageEntry] = {
35
+ path: StorageEntry(content=text, version=0)
36
+ for path, text in self.initial.items()
37
+ }
38
+
39
+ def read(self, path: str) -> StorageEntry:
40
+ entry = self._entries.get(path)
41
+ if entry is None:
42
+ raise FileNotFoundError(path)
43
+ return StorageEntry(content=entry.content, version=entry.version)
44
+
45
+ def write(self, path: str, content: str, *, version: int) -> StorageEntry:
46
+ entry = self._entries.get(path)
47
+ if entry and entry.version != version:
48
+ raise ValueError("version mismatch")
49
+ next_version = version + 1
50
+ entry = StorageEntry(content=content, version=next_version)
51
+ self._entries[path] = entry
52
+ return StorageEntry(content=entry.content, version=entry.version)
53
+
54
+ def list(self) -> Dict[str, StorageEntry]:
55
+ return {path: StorageEntry(content=entry.content, version=entry.version) for path, entry in self._entries.items()}
56
+
57
+ def delete(self, path: str) -> None:
58
+ self._entries.pop(path, None)
sandfs/exceptions.py ADDED
@@ -0,0 +1,32 @@
1
+ """Custom exceptions for sandfs."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SandboxError(RuntimeError):
7
+ """Base error for the sandbox."""
8
+
9
+
10
+ class NodeNotFound(SandboxError):
11
+ """Raised when a node cannot be resolved."""
12
+
13
+
14
+ class NodeExists(SandboxError):
15
+ """Raised when attempting to create a node that already exists."""
16
+
17
+
18
+ class InvalidOperation(SandboxError):
19
+ """Raised when an operation cannot be completed."""
20
+
21
+
22
+ class ProviderError(SandboxError):
23
+ """Raised when a provider callable fails."""
24
+
25
+
26
+ __all__ = [
27
+ "SandboxError",
28
+ "NodeNotFound",
29
+ "NodeExists",
30
+ "InvalidOperation",
31
+ "ProviderError",
32
+ ]
sandfs/hooks.py ADDED
@@ -0,0 +1,20 @@
1
+ """Hook dataclasses and type hints for persistence adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class WriteEvent:
11
+ path: str
12
+ content: str
13
+ version: int
14
+ append: bool
15
+
16
+
17
+ WriteHook = Callable[[WriteEvent], None]
18
+
19
+
20
+ __all__ = ["WriteEvent", "WriteHook"]
sandfs/integrations.py ADDED
@@ -0,0 +1,33 @@
1
+ """Integration helpers for path-scoped events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Callable, Dict, List, TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING: # pragma: no cover
9
+ from .vfs import VirtualFileSystem
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class PathEvent:
14
+ path: str
15
+ event: str # "create", "update", "delete"
16
+ content: str | None
17
+
18
+
19
+ PathHook = Callable[[PathEvent], None]
20
+
21
+
22
+ @dataclass
23
+ class InboxRecorder:
24
+ events: List[Dict[str, str | None]] = field(default_factory=list)
25
+
26
+ def attach(self, vfs: "VirtualFileSystem", prefix: str) -> None:
27
+ vfs.register_path_hook(prefix, self._handle)
28
+
29
+ def _handle(self, event: PathEvent) -> None:
30
+ self.events.append({"path": event.path, "event": event.event, "content": event.content})
31
+
32
+
33
+ __all__ = ["PathEvent", "PathHook", "InboxRecorder"]
sandfs/nodes.py ADDED
@@ -0,0 +1,166 @@
1
+ """Core node representations for the virtual filesystem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import PurePosixPath
7
+ from typing import Dict, Iterable, Iterator, Optional, TYPE_CHECKING
8
+
9
+ from .exceptions import InvalidOperation, NodeExists, NodeNotFound, ProviderError
10
+ from .policies import NodePolicy
11
+ from .providers import ContentProvider, DirectoryProvider, NodeContext, ProvidedNode
12
+
13
+ if TYPE_CHECKING: # pragma: no cover
14
+ from .vfs import VirtualFileSystem
15
+
16
+
17
+ @dataclass
18
+ class VirtualNode:
19
+ """Base node stored inside the sandbox."""
20
+
21
+ name: str
22
+ parent: Optional["VirtualDirectory"] = None
23
+ metadata: Dict[str, object] = field(default_factory=dict)
24
+ policy: NodePolicy = field(default_factory=NodePolicy)
25
+ version: int = 0
26
+
27
+ def path(self) -> PurePosixPath:
28
+ if self.parent is None:
29
+ return PurePosixPath("/")
30
+ segments = []
31
+ node: Optional[VirtualNode] = self
32
+ while node and node.parent is not None:
33
+ segments.append(node.name)
34
+ node = node.parent
35
+ return PurePosixPath("/" + "/".join(reversed(segments))) if segments else PurePosixPath("/")
36
+
37
+ def build_context(self, vfs: Optional["VirtualFileSystem"] = None) -> NodeContext:
38
+ return NodeContext(path=self.path(), metadata=self.metadata, vfs=vfs)
39
+
40
+
41
+ class VirtualFile(VirtualNode):
42
+ """Represents a file backed by either static text or a provider."""
43
+
44
+ def __init__(
45
+ self,
46
+ name: str,
47
+ *,
48
+ parent: Optional["VirtualDirectory"] = None,
49
+ content: str | None = None,
50
+ provider: Optional[ContentProvider] = None,
51
+ metadata: Optional[Dict[str, object]] = None,
52
+ ) -> None:
53
+ super().__init__(name=name, parent=parent, metadata=dict(metadata or {}))
54
+ self._content = content or ""
55
+ self._provider = provider
56
+
57
+ def read(self, vfs: Optional["VirtualFileSystem"] = None) -> str:
58
+ if self._provider is None:
59
+ return self._content
60
+ ctx = self.build_context(vfs)
61
+ try:
62
+ return self._provider(ctx)
63
+ except Exception as exc: # pragma: no cover - rewrap provider failures
64
+ raise ProviderError(str(exc)) from exc
65
+
66
+ def write(self, data: str, *, append: bool = False) -> None:
67
+ if append:
68
+ self._content += data
69
+ else:
70
+ self._content = data
71
+ self._provider = None
72
+
73
+ def set_provider(self, provider: ContentProvider) -> None:
74
+ self._provider = provider
75
+
76
+
77
+ class VirtualDirectory(VirtualNode):
78
+ """Directories store children lazily when a loader is present."""
79
+
80
+ def __init__(
81
+ self,
82
+ name: str,
83
+ *,
84
+ parent: Optional["VirtualDirectory"] = None,
85
+ loader: Optional[DirectoryProvider] = None,
86
+ metadata: Optional[Dict[str, object]] = None,
87
+ ) -> None:
88
+ super().__init__(name=name, parent=parent, metadata=dict(metadata or {}))
89
+ self.loader = loader
90
+ self._loaded = loader is None
91
+ self.children: Dict[str, VirtualNode] = {}
92
+
93
+ def ensure_loaded(self, vfs: Optional["VirtualFileSystem"] = None) -> None:
94
+ if self._loaded:
95
+ return
96
+ if self.loader is None:
97
+ self._loaded = True
98
+ return
99
+ ctx = self.build_context(vfs)
100
+ snapshot = self.loader(ctx)
101
+ for name, provided in snapshot.items():
102
+ if name in self.children:
103
+ continue
104
+ self.add_child(instantiate_provided_node(name, provided, parent=self))
105
+ self._loaded = True
106
+
107
+ def add_child(self, node: VirtualNode) -> None:
108
+ if node.name in self.children:
109
+ raise NodeExists(f"Node {node.name} already exists in {self.path()}")
110
+ node.parent = self
111
+ self.children[node.name] = node
112
+
113
+ def remove_child(self, name: str) -> None:
114
+ if name not in self.children:
115
+ raise NodeNotFound(f"Child {name} not found in {self.path()}")
116
+ del self.children[name]
117
+
118
+ def get_child(self, name: str, vfs: Optional["VirtualFileSystem"] = None) -> VirtualNode:
119
+ self.ensure_loaded(vfs)
120
+ try:
121
+ return self.children[name]
122
+ except KeyError as exc:
123
+ raise NodeNotFound(f"Child {name} not found in {self.path()}") from exc
124
+
125
+ def iter_children(self, vfs: Optional["VirtualFileSystem"] = None) -> Iterator[VirtualNode]:
126
+ self.ensure_loaded(vfs)
127
+ return iter(self.children.values())
128
+
129
+
130
+ def instantiate_provided_node(
131
+ name: str,
132
+ provided: ProvidedNode,
133
+ *,
134
+ parent: Optional[VirtualDirectory],
135
+ ) -> VirtualNode:
136
+ if provided.kind == "file":
137
+ node = VirtualFile(name=name, parent=parent, metadata=provided.metadata)
138
+ if provided.content_provider:
139
+ node.set_provider(provided.content_provider)
140
+ else:
141
+ node.write(provided.content or "")
142
+ if provided.policy is not None:
143
+ node.policy = provided.policy
144
+ return node
145
+ if provided.kind == "dir":
146
+ node = VirtualDirectory(
147
+ name=name,
148
+ parent=parent,
149
+ loader=provided.directory_provider,
150
+ metadata=provided.metadata,
151
+ )
152
+ if provided.children:
153
+ for child_name, child in provided.children.items():
154
+ node.add_child(instantiate_provided_node(child_name, child, parent=node))
155
+ if provided.policy is not None:
156
+ node.policy = provided.policy
157
+ return node
158
+ raise InvalidOperation(f"Unknown provided node kind: {provided.kind}")
159
+
160
+
161
+ __all__ = [
162
+ "VirtualNode",
163
+ "VirtualFile",
164
+ "VirtualDirectory",
165
+ "instantiate_provided_node",
166
+ ]
sandfs/policies.py ADDED
@@ -0,0 +1,55 @@
1
+ """Policy and visibility helpers for sandfs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import FrozenSet, Iterable, Optional, Set
7
+
8
+
9
+ @dataclass
10
+ class NodePolicy:
11
+ """Controls access, write semantics, and visibility for a node."""
12
+
13
+ readable: bool = True
14
+ writable: bool = True
15
+ append_only: bool = False
16
+ classification: str = "public"
17
+ principals: Set[str] = field(default_factory=set)
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class VisibilityView:
22
+ """Filters nodes by classification labels and principals."""
23
+
24
+ classifications: Optional[FrozenSet[str]] = None
25
+ principals: Optional[FrozenSet[str]] = None
26
+
27
+ def __init__(
28
+ self,
29
+ classifications: Optional[Iterable[str]] = None,
30
+ principals: Optional[Iterable[str]] = None,
31
+ ) -> None:
32
+ object.__setattr__(
33
+ self,
34
+ "classifications",
35
+ frozenset(classifications) if classifications is not None else None,
36
+ )
37
+ object.__setattr__(
38
+ self,
39
+ "principals",
40
+ frozenset(principals) if principals is not None else None,
41
+ )
42
+
43
+ def allows(self, policy: NodePolicy) -> bool:
44
+ if policy.principals:
45
+ if self.principals is None:
46
+ return False
47
+ if not (policy.principals & self.principals):
48
+ return False
49
+ return True
50
+ if self.classifications is None:
51
+ return True
52
+ return policy.classification in self.classifications
53
+
54
+
55
+ __all__ = ["NodePolicy", "VisibilityView"]
sandfs/providers.py ADDED
@@ -0,0 +1,82 @@
1
+ """Provider protocols and helper dataclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import PurePosixPath
7
+ from typing import Any, Callable, Dict, Iterable, Literal, Mapping, MutableMapping, Optional
8
+
9
+ if False: # pragma: no cover - for type checkers only
10
+ from .vfs import VirtualFileSystem
11
+ from .policies import NodePolicy
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class NodeContext:
16
+ """Context passed to providers when materializing nodes."""
17
+
18
+ path: PurePosixPath
19
+ metadata: Mapping[str, Any]
20
+ vfs: Optional["VirtualFileSystem"] = None
21
+
22
+
23
+ ContentProvider = Callable[[NodeContext], str]
24
+ DirectoryProvider = Callable[[NodeContext], "DirectorySnapshot"]
25
+ DirectorySnapshot = Mapping[str, "ProvidedNode"]
26
+
27
+
28
+ @dataclass
29
+ class ProvidedNode:
30
+ """Represents a node returned by a directory provider."""
31
+
32
+ kind: Literal["file", "dir"]
33
+ content: Optional[str] = None
34
+ content_provider: Optional[ContentProvider] = None
35
+ directory_provider: Optional[DirectoryProvider] = None
36
+ children: Optional[MutableMapping[str, "ProvidedNode"]] = None
37
+ metadata: Dict[str, Any] = field(default_factory=dict)
38
+ policy: Optional[NodePolicy] = None
39
+
40
+ @staticmethod
41
+ def file(
42
+ *,
43
+ content: Optional[str] = None,
44
+ content_provider: Optional[ContentProvider] = None,
45
+ metadata: Optional[Mapping[str, Any]] = None,
46
+ policy: Optional[NodePolicy] = None,
47
+ ) -> "ProvidedNode":
48
+ return ProvidedNode(
49
+ kind="file",
50
+ content=content,
51
+ content_provider=content_provider,
52
+ metadata=dict(metadata or {}),
53
+ policy=policy,
54
+ )
55
+
56
+ @staticmethod
57
+ def directory(
58
+ *,
59
+ children: Optional[Mapping[str, "ProvidedNode"]] = None,
60
+ directory_provider: Optional[DirectoryProvider] = None,
61
+ metadata: Optional[Mapping[str, Any]] = None,
62
+ policy: Optional[NodePolicy] = None,
63
+ ) -> "ProvidedNode":
64
+ frozen_children = None
65
+ if children is not None:
66
+ frozen_children = {name: child for name, child in children.items()}
67
+ return ProvidedNode(
68
+ kind="dir",
69
+ children=frozen_children,
70
+ directory_provider=directory_provider,
71
+ metadata=dict(metadata or {}),
72
+ policy=policy,
73
+ )
74
+
75
+
76
+ __all__ = [
77
+ "NodeContext",
78
+ "ContentProvider",
79
+ "DirectoryProvider",
80
+ "DirectorySnapshot",
81
+ "ProvidedNode",
82
+ ]
sandfs/pyexec.py ADDED
@@ -0,0 +1,82 @@
1
+ """Helper to execute Python snippets inside the sandbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import io
7
+ from dataclasses import dataclass
8
+ from typing import Any, Dict, Optional
9
+
10
+ from .vfs import VirtualFileSystem
11
+
12
+
13
+ _ALLOWED_BUILTINS = {
14
+ "abs": abs,
15
+ "all": all,
16
+ "any": any,
17
+ "bool": bool,
18
+ "dict": dict,
19
+ "enumerate": enumerate,
20
+ "float": float,
21
+ "int": int,
22
+ "len": len,
23
+ "list": list,
24
+ "map": map,
25
+ "max": max,
26
+ "min": min,
27
+ "print": print,
28
+ "range": range,
29
+ "repr": repr,
30
+ "reversed": reversed,
31
+ "round": round,
32
+ "set": set,
33
+ "sorted": sorted,
34
+ "str": str,
35
+ "sum": sum,
36
+ "tuple": tuple,
37
+ "zip": zip,
38
+ }
39
+
40
+
41
+ @dataclass
42
+ class PythonExecutionResult:
43
+ stdout: str
44
+ globals: Dict[str, Any]
45
+
46
+
47
+ class PythonExecutor:
48
+ def __init__(
49
+ self,
50
+ vfs: VirtualFileSystem,
51
+ *,
52
+ builtins: Optional[Dict[str, Any]] = None,
53
+ globals_template: Optional[Dict[str, Any]] = None,
54
+ ) -> None:
55
+ self.vfs = vfs
56
+ self._builtins = dict(_ALLOWED_BUILTINS)
57
+ if builtins:
58
+ self._builtins.update(builtins)
59
+ base = {"vfs": vfs, "fs": vfs}
60
+ if globals_template:
61
+ base.update(globals_template)
62
+ self._globals_template = base
63
+
64
+ def run(
65
+ self,
66
+ code: str,
67
+ *,
68
+ filename: str = "<sandbox>",
69
+ extra_globals: Optional[Dict[str, Any]] = None,
70
+ ) -> PythonExecutionResult:
71
+ env: Dict[str, Any] = dict(self._globals_template)
72
+ if extra_globals:
73
+ env.update(extra_globals)
74
+ env["__builtins__"] = dict(self._builtins)
75
+ stdout = io.StringIO()
76
+ with contextlib.redirect_stdout(stdout):
77
+ exec(compile(code, filename, "exec"), env, env)
78
+ env.pop("__builtins__", None)
79
+ return PythonExecutionResult(stdout=stdout.getvalue(), globals=env)
80
+
81
+
82
+ __all__ = ["PythonExecutor", "PythonExecutionResult"]