monkeyfs 0.1.0__tar.gz

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.
Files changed (34) hide show
  1. monkeyfs-0.1.0/LICENSE +21 -0
  2. monkeyfs-0.1.0/PKG-INFO +97 -0
  3. monkeyfs-0.1.0/README.md +61 -0
  4. monkeyfs-0.1.0/monkeyfs/__init__.py +23 -0
  5. monkeyfs-0.1.0/monkeyfs/base.py +159 -0
  6. monkeyfs-0.1.0/monkeyfs/config.py +98 -0
  7. monkeyfs-0.1.0/monkeyfs/context.py +29 -0
  8. monkeyfs-0.1.0/monkeyfs/isolated.py +526 -0
  9. monkeyfs-0.1.0/monkeyfs/patching/__init__.py +14 -0
  10. monkeyfs-0.1.0/monkeyfs/patching/core.py +148 -0
  11. monkeyfs-0.1.0/monkeyfs/patching/fdtable.py +226 -0
  12. monkeyfs-0.1.0/monkeyfs/patching/install.py +322 -0
  13. monkeyfs-0.1.0/monkeyfs/patching/patches.py +750 -0
  14. monkeyfs-0.1.0/monkeyfs/py.typed +0 -0
  15. monkeyfs-0.1.0/monkeyfs/virtual.py +1098 -0
  16. monkeyfs-0.1.0/monkeyfs/virtualfile.py +128 -0
  17. monkeyfs-0.1.0/monkeyfs.egg-info/PKG-INFO +97 -0
  18. monkeyfs-0.1.0/monkeyfs.egg-info/SOURCES.txt +32 -0
  19. monkeyfs-0.1.0/monkeyfs.egg-info/dependency_links.txt +1 -0
  20. monkeyfs-0.1.0/monkeyfs.egg-info/requires.txt +10 -0
  21. monkeyfs-0.1.0/monkeyfs.egg-info/top_level.txt +3 -0
  22. monkeyfs-0.1.0/pyproject.toml +65 -0
  23. monkeyfs-0.1.0/setup.cfg +4 -0
  24. monkeyfs-0.1.0/tests/test_bulk_operations.py +93 -0
  25. monkeyfs-0.1.0/tests/test_directory_ops.py +397 -0
  26. monkeyfs-0.1.0/tests/test_expanduser.py +163 -0
  27. monkeyfs-0.1.0/tests/test_fd_emulation.py +453 -0
  28. monkeyfs-0.1.0/tests/test_isolated.py +683 -0
  29. monkeyfs-0.1.0/tests/test_metadata.py +146 -0
  30. monkeyfs-0.1.0/tests/test_patching.py +890 -0
  31. monkeyfs-0.1.0/tests/test_patching_pathlib.py +252 -0
  32. monkeyfs-0.1.0/tests/test_vfs_optional.py +356 -0
  33. monkeyfs-0.1.0/tests/test_vfs_size_limit.py +296 -0
  34. monkeyfs-0.1.0/tests/test_virtual.py +320 -0
monkeyfs-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Ashenfelter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: monkeyfs
3
+ Version: 0.1.0
4
+ Summary: Transparent filesystem interception via monkey-patching.
5
+ Author: ashenfad
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ashenfad/monkeyfs
8
+ Project-URL: Bug Tracker, https://github.com/ashenfad/monkeyfs/issues
9
+ Project-URL: Documentation, https://github.com/ashenfad/monkeyfs#readme
10
+ Project-URL: Source, https://github.com/ashenfad/monkeyfs
11
+ Keywords: filesystem,monkey-patch,sandbox,vfs,interception
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: System :: Filesystems
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Provides-Extra: dev
28
+ Requires-Dist: ruff; extra == "dev"
29
+ Requires-Dist: pre-commit; extra == "dev"
30
+ Requires-Dist: pytest; extra == "dev"
31
+ Requires-Dist: pytest-timeout; extra == "dev"
32
+ Provides-Extra: test
33
+ Requires-Dist: pytest; extra == "test"
34
+ Requires-Dist: pytest-timeout; extra == "test"
35
+ Dynamic: license-file
36
+
37
+ # monkeyfs 🐒
38
+
39
+ Filesystem interception via monkey-patching.
40
+
41
+ Patches `open()`, `os.listdir()`, `os.stat()`, and 30+ other stdlib functions to route through a virtual or isolated filesystem. Patches are applied lazily on first `patch()` call and are inert outside the context. Uses `contextvars` for async-safe isolation between concurrent tasks. Zero dependencies.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install monkeyfs
47
+ ```
48
+
49
+ ## Quick example
50
+
51
+ ```python
52
+ from monkeyfs import VirtualFS, patch
53
+
54
+ vfs = VirtualFS({})
55
+
56
+ with patch(vfs):
57
+ with open("data.csv", "w") as f:
58
+ f.write("name,score\nalice,98\nbob,87\n")
59
+
60
+ import os
61
+ print(os.listdir("/")) # ['data.csv']
62
+ print(os.path.getsize("data.csv")) # 30
63
+
64
+ with open("data.csv") as f:
65
+ print(f.read()) # name,score\nalice,98\nbob,87\n
66
+ ```
67
+
68
+ ## IsolatedFS
69
+
70
+ Restricts file operations to a root directory on the real filesystem:
71
+
72
+ ```python
73
+ from monkeyfs import IsolatedFS, patch
74
+
75
+ isolated = IsolatedFS(root="/tmp/sandbox")
76
+
77
+ with patch(isolated):
78
+ with open("notes.txt", "w") as f:
79
+ f.write("hello") # Written to /tmp/sandbox/notes.txt
80
+
81
+ open("/etc/passwd") # PermissionError -- outside root
82
+ ```
83
+
84
+ ## Documentation
85
+
86
+ - [API Reference](docs/api.md) -- public API, FileSystem protocol, patched functions
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ uv sync --extra dev
92
+ uv run pytest
93
+ ```
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,61 @@
1
+ # monkeyfs 🐒
2
+
3
+ Filesystem interception via monkey-patching.
4
+
5
+ Patches `open()`, `os.listdir()`, `os.stat()`, and 30+ other stdlib functions to route through a virtual or isolated filesystem. Patches are applied lazily on first `patch()` call and are inert outside the context. Uses `contextvars` for async-safe isolation between concurrent tasks. Zero dependencies.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install monkeyfs
11
+ ```
12
+
13
+ ## Quick example
14
+
15
+ ```python
16
+ from monkeyfs import VirtualFS, patch
17
+
18
+ vfs = VirtualFS({})
19
+
20
+ with patch(vfs):
21
+ with open("data.csv", "w") as f:
22
+ f.write("name,score\nalice,98\nbob,87\n")
23
+
24
+ import os
25
+ print(os.listdir("/")) # ['data.csv']
26
+ print(os.path.getsize("data.csv")) # 30
27
+
28
+ with open("data.csv") as f:
29
+ print(f.read()) # name,score\nalice,98\nbob,87\n
30
+ ```
31
+
32
+ ## IsolatedFS
33
+
34
+ Restricts file operations to a root directory on the real filesystem:
35
+
36
+ ```python
37
+ from monkeyfs import IsolatedFS, patch
38
+
39
+ isolated = IsolatedFS(root="/tmp/sandbox")
40
+
41
+ with patch(isolated):
42
+ with open("notes.txt", "w") as f:
43
+ f.write("hello") # Written to /tmp/sandbox/notes.txt
44
+
45
+ open("/etc/passwd") # PermissionError -- outside root
46
+ ```
47
+
48
+ ## Documentation
49
+
50
+ - [API Reference](docs/api.md) -- public API, FileSystem protocol, patched functions
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ uv sync --extra dev
56
+ uv run pytest
57
+ ```
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,23 @@
1
+ """monkeyfs: Transparent filesystem interception via monkey-patching."""
2
+
3
+ from .base import FileInfo, FileMetadata, FileSystem
4
+ from .config import FSConfig, IsolatedFSConfig, VirtualFSConfig, connect_fs
5
+ from .context import current_fs, suspend
6
+ from .isolated import IsolatedFS
7
+ from .patching import patch
8
+ from .virtual import VirtualFS
9
+
10
+ __all__ = [
11
+ "connect_fs",
12
+ "current_fs",
13
+ "FileInfo",
14
+ "FileMetadata",
15
+ "FileSystem",
16
+ "FSConfig",
17
+ "IsolatedFS",
18
+ "IsolatedFSConfig",
19
+ "patch",
20
+ "suspend",
21
+ "VirtualFS",
22
+ "VirtualFSConfig",
23
+ ]
@@ -0,0 +1,159 @@
1
+ """Base filesystem interface and dataclasses.
2
+
3
+ Defines the common interface for filesystem implementations (VirtualFS, IsolatedFS).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from typing import Any, Protocol, runtime_checkable
12
+
13
+
14
+ @dataclass
15
+ class FileMetadata:
16
+ """Metadata for a single file or directory.
17
+
18
+ Attributes:
19
+ size: File size in bytes (0 for directories).
20
+ created_at: ISO 8601 timestamp when file was created (UTC).
21
+ modified_at: ISO 8601 timestamp when file was last modified (UTC).
22
+ is_dir: True if this is a directory, False for files.
23
+ """
24
+
25
+ size: int
26
+ created_at: str
27
+ modified_at: str
28
+ is_dir: bool = False
29
+
30
+ # os.stat_result-compatible properties — allows FileMetadata to be
31
+ # returned directly from stat() when used with sandtrap's os.stat() patch.
32
+
33
+ @property
34
+ def st_size(self) -> int:
35
+ return self.size
36
+
37
+ @property
38
+ def st_mode(self) -> int:
39
+ return 0o040755 if self.is_dir else 0o100644
40
+
41
+ @property
42
+ def st_ino(self) -> int:
43
+ return 0
44
+
45
+ @property
46
+ def st_dev(self) -> int:
47
+ return 0
48
+
49
+ @property
50
+ def st_nlink(self) -> int:
51
+ return 1
52
+
53
+ @property
54
+ def st_uid(self) -> int:
55
+ return os.getuid() if hasattr(os, "getuid") else 0
56
+
57
+ @property
58
+ def st_gid(self) -> int:
59
+ return os.getgid() if hasattr(os, "getgid") else 0
60
+
61
+ def _parse_ts(self, iso_str: str) -> float:
62
+ try:
63
+ return datetime.fromisoformat(iso_str).timestamp()
64
+ except ValueError:
65
+ return 0.0
66
+
67
+ @property
68
+ def st_atime(self) -> float:
69
+ return self._parse_ts(self.modified_at)
70
+
71
+ @property
72
+ def st_mtime(self) -> float:
73
+ return self._parse_ts(self.modified_at)
74
+
75
+ @property
76
+ def st_ctime(self) -> float:
77
+ return self._parse_ts(self.created_at)
78
+
79
+
80
+ @dataclass
81
+ class FileInfo:
82
+ """Complete file information for UI display.
83
+
84
+ Attributes:
85
+ name: File or directory name (basename).
86
+ path: Full path to file or directory.
87
+ size: File size in bytes (0 for directories).
88
+ created_at: ISO 8601 timestamp when created (UTC).
89
+ modified_at: ISO 8601 timestamp when last modified (UTC).
90
+ is_dir: True if this is a directory, False if file.
91
+ """
92
+
93
+ name: str
94
+ path: str
95
+ size: int
96
+ created_at: str
97
+ modified_at: str
98
+ is_dir: bool
99
+
100
+
101
+ @runtime_checkable
102
+ class FileSystem(Protocol):
103
+ """Minimal interface for patch() patching.
104
+
105
+ Only methods that the patching layer dispatches to are listed here.
106
+ Implementations may (and typically do) have additional methods like
107
+ read(), write(), glob(), list_detailed(), etc. — those are
108
+ application-level concerns, not part of the interception contract.
109
+
110
+ Required — patching will fail without these:
111
+ """
112
+
113
+ def open(self, path: str, mode: str = "r", **kwargs: Any) -> Any:
114
+ """Open a file."""
115
+ ...
116
+
117
+ def stat(self, path: str) -> FileMetadata:
118
+ """Get file metadata."""
119
+ ...
120
+
121
+ def exists(self, path: str) -> bool:
122
+ """Check if path exists."""
123
+ ...
124
+
125
+ def isfile(self, path: str) -> bool:
126
+ """Check if path is a file."""
127
+ ...
128
+
129
+ def isdir(self, path: str) -> bool:
130
+ """Check if path is a directory."""
131
+ ...
132
+
133
+ def list(self, path: str = ".", recursive: bool = False) -> list[str]:
134
+ """List directory contents (filenames only)."""
135
+ ...
136
+
137
+ def remove(self, path: str) -> None:
138
+ """Remove a file."""
139
+ ...
140
+
141
+ def mkdir(self, path: str, parents: bool = False, exist_ok: bool = False) -> None:
142
+ """Create a directory."""
143
+ ...
144
+
145
+ def makedirs(self, path: str, exist_ok: bool = True) -> None:
146
+ """Create directory tree."""
147
+ ...
148
+
149
+ def rename(self, src: str, dst: str) -> None:
150
+ """Rename/move a file or directory."""
151
+ ...
152
+
153
+ def getcwd(self) -> str:
154
+ """Get current working directory."""
155
+ ...
156
+
157
+ def chdir(self, path: str) -> None:
158
+ """Change current working directory."""
159
+ ...
@@ -0,0 +1,98 @@
1
+ """Configuration for filesystem access.
2
+
3
+ Provides configuration dataclasses and connect_fs factory function for
4
+ configuring filesystem access (virtual or isolated).
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Literal
9
+
10
+
11
+ @dataclass
12
+ class VirtualFSConfig:
13
+ """Configuration for virtual (in-memory) filesystem.
14
+
15
+ Attributes:
16
+ type: Always "virtual".
17
+ max_size_mb: Maximum total size of all files in megabytes.
18
+ None means unlimited.
19
+ """
20
+
21
+ type: Literal["virtual"] = "virtual"
22
+ max_size_mb: int | None = None
23
+
24
+
25
+ @dataclass
26
+ class IsolatedFSConfig:
27
+ """Configuration for isolated (real) filesystem with path restriction.
28
+
29
+ Attributes:
30
+ type: Always "isolated".
31
+ root: Absolute path to root directory (all file operations restricted to this path).
32
+ """
33
+
34
+ type: Literal["isolated"] = "isolated"
35
+ root: str = ""
36
+
37
+
38
+ # Type alias for all filesystem configs
39
+ FSConfig = VirtualFSConfig | IsolatedFSConfig
40
+
41
+
42
+ def connect_fs(
43
+ type: Literal["virtual", "isolated"] = "virtual",
44
+ **kwargs,
45
+ ) -> FSConfig:
46
+ """Configure filesystem access.
47
+
48
+ Creates a filesystem configuration.
49
+
50
+ Args:
51
+ type: FileSystem type.
52
+ - "virtual": In-memory filesystem backed by a mapping.
53
+ Files persist with state and participate in versioning.
54
+ - "isolated": Real filesystem restricted to a directory.
55
+ Requires 'root' argument.
56
+ **kwargs: Additional configuration for the filesystem type.
57
+ For type="virtual":
58
+ - max_size_mb (int): Optional. Max total file size in MB.
59
+ For type="isolated":
60
+ - root (str): Required. Absolute path to root directory.
61
+
62
+ Returns:
63
+ FSConfig for initialization.
64
+
65
+ Examples:
66
+ Virtual filesystem:
67
+ >>> connect_fs(type="virtual")
68
+ VirtualFSConfig(type='virtual', max_size_mb=None)
69
+
70
+ Isolated filesystem:
71
+ >>> connect_fs(type="isolated", root="/path/to/project")
72
+ IsolatedFSConfig(type='isolated', root='/path/to/project')
73
+ """
74
+ if type == "virtual":
75
+ max_size_mb = kwargs.pop("max_size_mb", None)
76
+ if kwargs:
77
+ raise ValueError(
78
+ f"Unexpected arguments for virtual fs: {list(kwargs.keys())}"
79
+ )
80
+ return VirtualFSConfig(type=type, max_size_mb=max_size_mb)
81
+
82
+ elif type == "isolated":
83
+ root = kwargs.pop("root", "")
84
+
85
+ if kwargs:
86
+ raise ValueError(
87
+ f"Unexpected arguments for isolated fs: {list(kwargs.keys())}"
88
+ )
89
+
90
+ if not root:
91
+ raise ValueError("Isolated filesystem requires 'root' parameter")
92
+
93
+ return IsolatedFSConfig(root=root)
94
+
95
+ else:
96
+ raise ValueError(
97
+ f"Unsupported filesystem type: {type}. Use 'virtual' or 'isolated'."
98
+ )
@@ -0,0 +1,29 @@
1
+ """Context variables for filesystem isolation.
2
+
3
+ Shared context variables used by patching.py and filesystem implementations
4
+ to coordinate filesystem routing and prevent recursion loops.
5
+ """
6
+
7
+ import contextvars
8
+ from contextlib import contextmanager
9
+ from typing import Any, Iterator
10
+
11
+ # Context variable holding the current filesystem
12
+ current_fs: contextvars.ContextVar[Any] = contextvars.ContextVar(
13
+ "monkeyfs_current_fs", default=None
14
+ )
15
+
16
+
17
+ @contextmanager
18
+ def suspend() -> Iterator[None]:
19
+ """Temporarily disable filesystem interception in the current context.
20
+
21
+ Use this when implementing internal filesystem operations (like inside
22
+ IsolatedFS) that need to perform real I/O without triggering the
23
+ patched functions recursively.
24
+ """
25
+ token = current_fs.set(None)
26
+ try:
27
+ yield
28
+ finally:
29
+ current_fs.reset(token)