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 +30 -0
- sandfs/adapters.py +58 -0
- sandfs/exceptions.py +32 -0
- sandfs/hooks.py +20 -0
- sandfs/integrations.py +33 -0
- sandfs/nodes.py +166 -0
- sandfs/policies.py +55 -0
- sandfs/providers.py +82 -0
- sandfs/pyexec.py +82 -0
- sandfs/shell.py +395 -0
- sandfs/vfs.py +599 -0
- sandfs-0.1.0.dist-info/METADATA +194 -0
- sandfs-0.1.0.dist-info/RECORD +15 -0
- sandfs-0.1.0.dist-info/WHEEL +4 -0
- sandfs-0.1.0.dist-info/licenses/LICENSE +21 -0
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"]
|