anyshell 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.
anyshell/filesystem.py ADDED
@@ -0,0 +1,349 @@
1
+ """Workspace filesystems with a common POSIX-style path namespace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import posixpath
6
+ import shutil
7
+ import time
8
+ from collections.abc import Iterator
9
+ from io import StringIO
10
+ from pathlib import Path
11
+ from typing import Literal, Protocol
12
+
13
+
14
+ class FileSystem(Protocol):
15
+ def listdir(self, path: str) -> list[dict]: ...
16
+ def stat(self, path: str) -> dict: ...
17
+ def entry_type(self, path: str) -> Literal["file", "directory", "symlink"]: ...
18
+ def is_dir(self, path: str) -> bool: ...
19
+ def read_lines(self, path: str) -> Iterator[str]: ...
20
+ def read_text(self, path: str) -> str: ...
21
+ def write_text(self, path: str, text: str) -> None: ...
22
+ def mkdir(self, path: str) -> None: ...
23
+ def rmdir(self, path: str) -> None: ...
24
+ def touch(self, path: str) -> None: ...
25
+ def remove(self, path: str) -> None: ...
26
+ def move(self, source: str, destination: str) -> None: ...
27
+ def copy(
28
+ self, source: str, destination: str, *, recursive: bool = False
29
+ ) -> None: ...
30
+
31
+
32
+ def _validate_transfer(fs: FileSystem, source: str, destination: str) -> bool:
33
+ """Validate exact transfer paths before changing either filesystem."""
34
+ fs.stat(source)
35
+ if source == "/" or destination == "/":
36
+ raise PermissionError("cannot copy or move the workspace root")
37
+ if source == destination:
38
+ raise OSError("source and destination are the same path")
39
+ directory = fs.is_dir(source)
40
+ if directory and destination.startswith(source + "/"):
41
+ raise OSError("cannot copy or move a directory into itself")
42
+ if fs.is_dir(destination):
43
+ raise FileExistsError(f"{destination!r}: directory already exists")
44
+ if directory:
45
+ try:
46
+ fs.stat(destination)
47
+ except FileNotFoundError:
48
+ pass
49
+ else:
50
+ raise NotADirectoryError(f"{destination!r} is a file")
51
+ return directory
52
+
53
+
54
+ def resolve_path(cwd: str, path: str) -> str:
55
+ """Normalize a logical path without changing the process working directory."""
56
+ return "/" + posixpath.normpath(posixpath.join(cwd, path)).lstrip("/")
57
+
58
+
59
+ class LocalFileSystem:
60
+ """Map logical / to a workspace directory, including on Windows.
61
+
62
+ Resolved symlinks must stay in that workspace. This is a convenience boundary,
63
+ not a security sandbox for Python commands or concurrent filesystem changes.
64
+ """
65
+
66
+ def __init__(self, root: str | Path = ".") -> None:
67
+ self.root = Path(root).resolve(strict=True)
68
+ if not self.root.is_dir():
69
+ raise NotADirectoryError(str(root))
70
+
71
+ def _path(self, path: str) -> Path:
72
+ candidate = (self.root / path.lstrip("/")).resolve()
73
+ if not candidate.is_relative_to(self.root):
74
+ raise PermissionError("path leaves the workspace")
75
+ return candidate
76
+
77
+ def is_dir(self, path: str) -> bool:
78
+ return self._path(path).is_dir()
79
+
80
+ def listdir(self, path: str) -> list[dict]:
81
+ return [
82
+ {"name": child.name, "type": "directory" if child.is_dir() else "file"}
83
+ for child in sorted(self._path(path).iterdir(), key=lambda p: p.name)
84
+ ]
85
+
86
+ def entry_type(self, path: str) -> Literal["file", "directory", "symlink"]:
87
+ if path == "/":
88
+ return "directory"
89
+ entry = self.root / path.lstrip("/")
90
+ parent = entry.parent.resolve()
91
+ if not parent.is_relative_to(self.root):
92
+ raise PermissionError("path leaves the workspace")
93
+ entry = parent / entry.name
94
+ if entry.is_symlink():
95
+ return "symlink"
96
+ entry.stat() # Distinguish missing entries from regular files.
97
+ return "directory" if entry.is_dir() else "file"
98
+
99
+ def stat(self, path: str) -> dict:
100
+ candidate = self._path(path)
101
+ metadata = candidate.stat()
102
+ return {
103
+ "size": None if candidate.is_dir() else metadata.st_size,
104
+ "modified": metadata.st_mtime,
105
+ }
106
+
107
+ def read_text(self, path: str) -> str:
108
+ with self._path(path).open(encoding="utf-8", newline="") as stream:
109
+ return stream.read()
110
+
111
+ def read_lines(self, path: str) -> Iterator[str]:
112
+ with self._path(path).open(encoding="utf-8") as stream:
113
+ for line in stream:
114
+ yield line.rstrip("\r\n")
115
+
116
+ def write_text(self, path: str, text: str) -> None:
117
+ self._path(path).write_text(text, encoding="utf-8", newline="")
118
+
119
+ def mkdir(self, path: str) -> None:
120
+ self._path(path).mkdir()
121
+
122
+ def rmdir(self, path: str) -> None:
123
+ # Preserve rmdir semantics: a symlink to a directory is not a directory.
124
+ unresolved = self.root / path.lstrip("/")
125
+ candidate = self._path(path)
126
+ if candidate == self.root:
127
+ raise PermissionError("cannot remove the workspace root")
128
+ if unresolved.is_symlink():
129
+ raise NotADirectoryError(path)
130
+ candidate.rmdir()
131
+
132
+ def touch(self, path: str) -> None:
133
+ self._path(path).touch()
134
+
135
+ def remove(self, path: str) -> None:
136
+ unresolved = self.root / path.lstrip("/")
137
+ if unresolved.is_symlink():
138
+ if not unresolved.parent.resolve().is_relative_to(self.root):
139
+ raise PermissionError("path leaves the workspace")
140
+ unresolved.unlink()
141
+ return
142
+ candidate = self._path(path)
143
+ if candidate == self.root:
144
+ raise PermissionError("cannot remove the workspace root")
145
+ if candidate.is_dir():
146
+ raise IsADirectoryError(path)
147
+ candidate.unlink()
148
+
149
+ def move(self, source: str, destination: str) -> None:
150
+ _validate_transfer(self, source, destination)
151
+ # Rename the entry itself, rather than a symlink's target. Resolve parent
152
+ # directories to keep both endpoints inside the workspace.
153
+ entries = []
154
+ for path in (source, destination):
155
+ entry = self.root / path.lstrip("/")
156
+ parent = entry.parent.resolve()
157
+ if not parent.is_relative_to(self.root):
158
+ raise PermissionError("path leaves the workspace")
159
+ entries.append(parent / entry.name)
160
+ original, target = entries
161
+ if target.exists() and original.samefile(target):
162
+ raise OSError("source and destination are the same file")
163
+ if (
164
+ original.is_dir()
165
+ and not original.is_symlink()
166
+ and target.is_relative_to(original.resolve())
167
+ ):
168
+ raise OSError("cannot move a directory into itself")
169
+ original.replace(target)
170
+
171
+ def copy(self, source: str, destination: str, *, recursive: bool = False) -> None:
172
+ directory = _validate_transfer(self, source, destination)
173
+ original, target = self._path(source), self._path(destination)
174
+ if directory:
175
+ if not recursive:
176
+ raise IsADirectoryError(f"{source!r} is a directory; use cp -r")
177
+ if target.is_relative_to(original):
178
+ raise OSError("cannot copy a directory into itself")
179
+ # Preserve nested links rather than following them outside the workspace.
180
+ shutil.copytree(original, target, symlinks=True)
181
+ else:
182
+ shutil.copy2(original, target)
183
+
184
+
185
+ class MemoryFileSystem:
186
+ """An in-memory workspace; no disk, network, processes, or threads required."""
187
+
188
+ def __init__(self, files: dict[str, str] | None = None) -> None:
189
+ self.files: dict[str, str] = {}
190
+ self.directories = {"/"}
191
+ self._modified = {"/": time.time()}
192
+ for name, content in (files or {}).items():
193
+ path = resolve_path("/", name)
194
+ if path in self.directories:
195
+ raise IsADirectoryError(path)
196
+ parent = posixpath.dirname(path)
197
+ while parent != "/":
198
+ if parent in self.files:
199
+ raise NotADirectoryError(parent)
200
+ self.directories.add(parent)
201
+ self._modified.setdefault(parent, time.time())
202
+ parent = posixpath.dirname(parent)
203
+ self.files[path] = content
204
+ self._modified[path] = time.time()
205
+
206
+ def is_dir(self, path: str) -> bool:
207
+ return path in self.directories
208
+
209
+ def listdir(self, path: str) -> list[dict]:
210
+ if path not in self.directories:
211
+ raise NotADirectoryError(path)
212
+ rows = [
213
+ {"name": posixpath.basename(item), "type": kind}
214
+ for items, kind in ((self.files, "file"), (self.directories, "directory"))
215
+ for item in items
216
+ if item != path and posixpath.dirname(item) == path
217
+ ]
218
+ return sorted(rows, key=lambda row: row["name"])
219
+
220
+ def entry_type(self, path: str) -> Literal["file", "directory", "symlink"]:
221
+ if path in self.directories:
222
+ return "directory"
223
+ if path in self.files:
224
+ return "file"
225
+ raise FileNotFoundError(path)
226
+
227
+ def stat(self, path: str) -> dict:
228
+ if path not in self.files and path not in self.directories:
229
+ raise FileNotFoundError(path)
230
+ return {
231
+ "size": len(self.files[path].encode("utf-8"))
232
+ if path in self.files
233
+ else None,
234
+ "modified": self._modified[path],
235
+ }
236
+
237
+ def _touch(self, path: str) -> None:
238
+ self._modified[path] = time.time()
239
+ self._modified[posixpath.dirname(path)] = time.time()
240
+
241
+ def read_text(self, path: str) -> str:
242
+ if path in self.directories:
243
+ raise IsADirectoryError(path)
244
+ try:
245
+ return self.files[path]
246
+ except KeyError:
247
+ raise FileNotFoundError(path) from None
248
+
249
+ def read_lines(self, path: str) -> Iterator[str]:
250
+ if path in self.directories:
251
+ raise IsADirectoryError(path)
252
+ try:
253
+ return (
254
+ line.rstrip("\r\n") for line in StringIO(self.files[path], newline=None)
255
+ )
256
+ except KeyError:
257
+ raise FileNotFoundError(path) from None
258
+
259
+ def _check_parent(self, path: str) -> None:
260
+ if posixpath.dirname(path) not in self.directories:
261
+ raise FileNotFoundError(posixpath.dirname(path))
262
+
263
+ def write_text(self, path: str, text: str) -> None:
264
+ self._check_parent(path)
265
+ if path in self.directories:
266
+ raise IsADirectoryError(path)
267
+ self.files[path] = text
268
+ self._touch(path)
269
+
270
+ def mkdir(self, path: str) -> None:
271
+ self._check_parent(path)
272
+ if path in self.files or path in self.directories:
273
+ raise FileExistsError(path)
274
+ self.directories.add(path)
275
+ self._touch(path)
276
+
277
+ def rmdir(self, path: str) -> None:
278
+ if path in self.files:
279
+ raise NotADirectoryError(path)
280
+ if path not in self.directories:
281
+ raise FileNotFoundError(path)
282
+ if path == "/":
283
+ raise PermissionError("cannot remove the workspace root")
284
+ if self.listdir(path):
285
+ raise OSError("directory is not empty")
286
+ self.directories.remove(path)
287
+ self._modified.pop(path, None)
288
+ self._modified[posixpath.dirname(path)] = time.time()
289
+
290
+ def touch(self, path: str) -> None:
291
+ if path in self.files or path in self.directories:
292
+ self._modified[path] = time.time()
293
+ else:
294
+ self.write_text(path, "")
295
+
296
+ def remove(self, path: str) -> None:
297
+ if path == "/":
298
+ raise PermissionError("cannot remove the workspace root")
299
+ if path in self.files:
300
+ del self.files[path]
301
+ elif path in self.directories:
302
+ raise IsADirectoryError(path)
303
+ else:
304
+ raise FileNotFoundError(path)
305
+ self._modified.pop(path, None)
306
+ self._modified[posixpath.dirname(path)] = time.time()
307
+
308
+ def _transfer(
309
+ self, source: str, destination: str, *, move: bool, recursive: bool
310
+ ) -> None:
311
+ directory = _validate_transfer(self, source, destination)
312
+ if directory and not recursive:
313
+ raise IsADirectoryError(f"{source!r} is a directory; use cp -r")
314
+ self._check_parent(destination)
315
+ paths = {
316
+ path
317
+ for path in self._modified
318
+ if path == source or (directory and path.startswith(source + "/"))
319
+ }
320
+ replacements = {path: destination + path[len(source) :] for path in paths}
321
+ files = {
322
+ target: self.files[path]
323
+ for path, target in replacements.items()
324
+ if path in self.files
325
+ }
326
+ directories = {
327
+ target for path, target in replacements.items() if path in self.directories
328
+ }
329
+ modified = {
330
+ target: self._modified[path] for path, target in replacements.items()
331
+ }
332
+ if move:
333
+ for path in paths:
334
+ self.files.pop(path, None)
335
+ self.directories.discard(path)
336
+ self._modified.pop(path, None)
337
+ self.files.update(files)
338
+ self.directories.update(directories)
339
+ self._modified.update(modified)
340
+ now = time.time()
341
+ self._modified[posixpath.dirname(destination)] = now
342
+ if move:
343
+ self._modified[posixpath.dirname(source)] = now
344
+
345
+ def move(self, source: str, destination: str) -> None:
346
+ self._transfer(source, destination, move=True, recursive=True)
347
+
348
+ def copy(self, source: str, destination: str, *, recursive: bool = False) -> None:
349
+ self._transfer(source, destination, move=False, recursive=recursive)