Skip to content

Commit 506c4d5

Browse files
authored
refactor: add custom repr for file system (#21)
* refactor: add custom repr for file system * refactor: remove redundant properties * refactor: remove build unsafe * refactor: add additional check for special nodes * chore: bump version * Apply suggestions from code review Co-authored-by: NTFSvolume <172021377+NTFSvolume@users.noreply.github.com> * tests: update tests
1 parent fbce09c commit 506c4d5

4 files changed

Lines changed: 46 additions & 118 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ license = "Apache-2.0"
3434
license-files = ["LICENSE"]
3535
readme = "README.md"
3636
requires-python = ">=3.11"
37-
version = "2.3.1"
37+
version = "2.4.0"
3838

3939
[project.optional-dependencies]
4040
cli = [

src/mega/filesystem.py

Lines changed: 43 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from typing import TYPE_CHECKING, Any, Final, NamedTuple, Self
1313

1414
from mega.data_structures import Node, NodeID, NodeType, _DictDumper
15-
from mega.errors import MultipleNodesFoundError
15+
from mega.errors import MultipleNodesFoundError, ValidationError
1616

1717
if TYPE_CHECKING:
1818
from collections.abc import Generator, Iterable, Iterator
@@ -48,20 +48,23 @@ def walk(node_id: str, current_path: PurePosixPath) -> Generator[tuple[NodeID, P
4848

4949
@dataclasses.dataclass(slots=True, frozen=True, kw_only=True, weakref_slot=True)
5050
class _NodeWalker:
51-
_nodes: MappingProxyType[NodeID, Node] = dataclasses.field(repr=False)
52-
_children: MappingProxyType[NodeID, tuple[NodeID, ...]] = dataclasses.field(repr=False)
51+
nodes: MappingProxyType[NodeID, Node] = dataclasses.field(repr=False)
52+
"A mapping of every node"
53+
54+
children: MappingProxyType[NodeID, tuple[NodeID, ...]] = dataclasses.field(repr=False)
55+
"A mapping of nodes to their inmediate children"
5356

5457
def _ls(self, node_id: NodeID, *, recursive: bool) -> Iterable[NodeID]:
5558
"""Get ID of every child of this node"""
56-
for child_id in self._children.get(node_id, ()):
59+
for child_id in self.children.get(node_id, ()):
5760
yield child_id
5861
if recursive:
5962
yield from self._ls(child_id, recursive=recursive)
6063

6164
def iterdir(self, node_id: NodeID, *, recursive: bool = False) -> Iterable[Node]:
6265
"""Iterate over the children in this node"""
6366
for child_id in self._ls(node_id, recursive=recursive):
64-
yield self._nodes[child_id]
67+
yield self.nodes[child_id]
6568

6669
def listdir(self, node_id: NodeID) -> list[Node]:
6770
"""Get a list of children of this node (non recursive)"""
@@ -82,6 +85,16 @@ class SimpleFileSystem(_NodeWalker, _DictDumper):
8285
file_count: int
8386
folder_count: int
8487

88+
def __repr__(self) -> str:
89+
def fields():
90+
for name, node in [("root", self.root), ("inbox", self.inbox), ("trash_bin", self.trash_bin)]:
91+
yield name, node.id if node is not None else None
92+
yield "files", self.file_count
93+
yield "folders", self.file_count
94+
95+
all_fields = ", ".join(f"{name}={value!r}" for name, value in fields())
96+
return f"<{type(self).__name__}({all_fields})>"
97+
8598
def __len__(self) -> int:
8699
return len(self.nodes)
87100

@@ -95,16 +108,6 @@ def __getitem__(self, node_id: NodeID) -> Node:
95108
"""Get the node with this ID"""
96109
return self.nodes[node_id]
97110

98-
@property
99-
def nodes(self) -> MappingProxyType[NodeID, Node]:
100-
"""A mapping of every node"""
101-
return self._nodes
102-
103-
@property
104-
def children(self) -> MappingProxyType[NodeID, tuple[NodeID, ...]]:
105-
"""A mapping of nodes to their inmediate children"""
106-
return self._children
107-
108111
@property
109112
def deleted(self) -> Iterable[Node]:
110113
"""Files or folders currently on the trash bin (Non recursive)"""
@@ -119,6 +122,11 @@ def build(cls, nodes: Iterable[Node]) -> Self:
119122
nodes_map: dict[NodeID, Node] = {}
120123
children: dict[NodeID, list[NodeID]] = {}
121124

125+
def sanity_check(current: Node | None, found: Node) -> None:
126+
if current is not None:
127+
ids = current.id, found.id
128+
raise ValidationError(f"Multiple nodes of type {found.type.name} found: {ids}")
129+
122130
for node in nodes:
123131
nodes_map[node.id] = node
124132
if node.parent_id:
@@ -129,10 +137,13 @@ def build(cls, nodes: Iterable[Node]) -> Self:
129137
case NodeType.FOLDER:
130138
folder_count += 1
131139
case NodeType.ROOT_FOLDER:
140+
sanity_check(root, node)
132141
root = node
133142
case NodeType.INBOX:
143+
sanity_check(inbox, node)
134144
inbox = node
135145
case NodeType.TRASH:
146+
sanity_check(trash_bin, node)
136147
trash_bin = node
137148
case _:
138149
raise RuntimeError # pyright: ignore[reportUnreachable]
@@ -143,8 +154,8 @@ def build(cls, nodes: Iterable[Node]) -> Self:
143154
trash_bin=trash_bin,
144155
file_count=file_count,
145156
folder_count=folder_count,
146-
_nodes=MappingProxyType(nodes_map),
147-
_children=MappingProxyType({node_id: tuple(nodes) for node_id, nodes in children.items()}),
157+
nodes=MappingProxyType(nodes_map),
158+
children=MappingProxyType({node_id: tuple(nodes) for node_id, nodes in children.items()}),
148159
)
149160

150161
def dump(self) -> dict[str, Any]:
@@ -173,8 +184,12 @@ class FileSystem(SimpleFileSystem):
173184
NOTE: Mega's filesystem is **not POSIX-compliant**: multiple nodes may have the same path
174185
"""
175186

176-
_paths: MappingProxyType[NodeID, PurePosixPath] = dataclasses.field(repr=False)
177-
_inv_paths: MappingProxyType[PurePosixPath, tuple[NodeID, ...]] = dataclasses.field(repr=False)
187+
paths: MappingProxyType[NodeID, PurePosixPath] = dataclasses.field(repr=False)
188+
"""A mapping of every node to its absolute path within the filesystem"""
189+
190+
inv_paths: MappingProxyType[PurePosixPath, tuple[NodeID, ...]] = dataclasses.field(repr=False)
191+
"""A mapping of paths to every node located at that path"""
192+
178193
_deleted: frozenset[NodeID] = dataclasses.field(repr=False)
179194

180195
@classmethod
@@ -209,26 +224,13 @@ def build(cls, nodes: Iterable[Node]) -> Self:
209224
trash_bin=self.trash_bin,
210225
file_count=self.file_count,
211226
folder_count=self.folder_count,
212-
_nodes=self.nodes,
213-
_children=self.children,
214-
_paths=MappingProxyType(paths),
215-
_inv_paths=MappingProxyType({path: tuple(nodes) for path, nodes in inv_paths.items()}),
227+
nodes=self.nodes,
228+
children=self.children,
229+
paths=MappingProxyType(paths),
230+
inv_paths=MappingProxyType({path: tuple(nodes) for path, nodes in inv_paths.items()}),
216231
_deleted=frozenset(deleted_ids),
217232
)
218233

219-
@property
220-
def paths(self) -> MappingProxyType[NodeID, PurePosixPath]:
221-
"""A mapping of every node to its absolute path within the filesystem"""
222-
return self._paths
223-
224-
@property
225-
def inv_paths(self) -> MappingProxyType[PurePosixPath, tuple[NodeID, ...]]:
226-
"""A mapping of paths to every node located at that path
227-
228-
Mega's filesystem is **not POSIX-compliant**: multiple nodes may have the same path
229-
"""
230-
return self._inv_paths
231-
232234
@property
233235
def files(self) -> Iterable[Node]:
234236
"""All files that are NOT deleted (recursive)"""
@@ -260,7 +262,7 @@ def relative_path(self, node_id: NodeID) -> PurePosixPath:
260262

261263
def absolute_path(self, node_id: NodeID) -> PurePosixPath:
262264
"""Get the absolute path of this node"""
263-
return self._paths[node_id]
265+
return self.paths[node_id]
264266

265267
def search(
266268
self,
@@ -271,7 +273,7 @@ def search(
271273
"""Returns nodes that have "query" as a substring on their path"""
272274
query = PurePosixPath(query).as_posix()
273275

274-
for node_id, path in self._paths.items():
276+
for node_id, path in self.paths.items():
275277
if query not in path.as_posix():
276278
continue
277279

@@ -292,7 +294,7 @@ def find(self, path: str | PathLike[str]) -> Node:
292294
"""
293295
path = POSIX_ROOT / PurePosixPath(path)
294296
try:
295-
nodes = self._inv_paths[path]
297+
nodes = self.inv_paths[path]
296298
except LookupError:
297299
msg = f'A node with path "{path!s}" does not exists'
298300
raise FileNotFoundError(errno.ENOENT, msg) from None
@@ -332,8 +334,8 @@ def dump(self, *, simple: bool = False) -> dict[str, Any]:
332334

333335
return dump | dict( # noqa: C408
334336
deleted=sorted(self._deleted),
335-
paths={node_id: str(path) for node_id, path in self._paths.items()},
336-
inv_paths={str(path): node_id for path, node_id in self._inv_paths.items()},
337+
paths={node_id: str(path) for node_id, path in self.paths.items()},
338+
inv_paths={str(path): node_id for path, node_id in self.inv_paths.items()},
337339
children=dict(self.children),
338340
)
339341

@@ -344,72 +346,6 @@ class UserFileSystem(FileSystem):
344346
inbox: Node
345347
trash_bin: Node
346348

347-
@classmethod
348-
def build_unsafe(cls, nodes: Iterable[Node]) -> Self:
349-
"""Build a filesystem from a pre-ordered node stream without validation
350-
351-
Nodes MUST be topologically sorted:
352-
- The first three nodes are ROOT, INBOX and TRASH_BIN.
353-
- Every parent appears before its descendants.
354-
"""
355-
# Build fs using only 3 loops:
356-
# - 1. Create a map to all nodes and resolve their paths
357-
# - 2. Freeze children (list -> tuple)
358-
# - 3. Freeze inv paths (list -> tuple)
359-
root = inbox = trash_bin = None
360-
file_count = folder_count = 0
361-
362-
paths: dict[NodeID, PurePosixPath] = {}
363-
inv_paths: dict[PurePosixPath, list[NodeID]] = {}
364-
deleted_ids: set[NodeID] = set()
365-
nodes_map: dict[NodeID, Node] = {}
366-
children: dict[NodeID, list[NodeID]] = {}
367-
trash_bin_id = None
368-
369-
for node in nodes:
370-
nodes_map[node.id] = node
371-
if node.parent_id:
372-
children.setdefault(node.parent_id, []).append(node.id)
373-
374-
path = None
375-
match node.type:
376-
case NodeType.FILE:
377-
file_count += 1
378-
case NodeType.FOLDER:
379-
folder_count += 1
380-
case NodeType.ROOT_FOLDER:
381-
path = POSIX_ROOT
382-
root = node
383-
case NodeType.INBOX:
384-
path = POSIX_ROOT / node.attributes.name
385-
inbox = node
386-
case NodeType.TRASH:
387-
path = POSIX_ROOT / node.attributes.name
388-
trash_bin_id = node.id
389-
trash_bin = node
390-
case _:
391-
raise RuntimeError # pyright: ignore[reportUnreachable]
392-
393-
path = path or (paths[node.parent_id] / node.attributes.name)
394-
paths[node.id] = path
395-
inv_paths.setdefault(path, []).append(node.id)
396-
if node.parent_id == trash_bin_id or node.parent_id in deleted_ids:
397-
deleted_ids.add(node.id)
398-
399-
assert root and inbox and trash_bin
400-
return cls(
401-
root=root,
402-
inbox=inbox,
403-
trash_bin=trash_bin,
404-
file_count=file_count,
405-
folder_count=folder_count,
406-
_nodes=MappingProxyType(nodes_map),
407-
_children=MappingProxyType({node_id: tuple(nodes) for node_id, nodes in children.items()}),
408-
_paths=MappingProxyType(paths),
409-
_inv_paths=MappingProxyType({path: tuple(nodes) for path, nodes in inv_paths.items()}),
410-
_deleted=frozenset(deleted_ids),
411-
)
412-
413349

414350
if __name__ == "__main__":
415351
import json
@@ -423,7 +359,5 @@ def build_unsafe(cls, nodes: Iterable[Node]) -> Self:
423359
print(f"Testing filesystem build of {len(nodes):_} nodes") # noqa: T201
424360

425361
iterations = 1_000
426-
unsafe = timeit.timeit(lambda: UserFileSystem.build_unsafe(nodes), number=iterations)
427-
print(f"{iterations} [UNSAFE] calls took {unsafe:.4f}s") # noqa: T201
428362
safe = timeit.timeit(lambda: UserFileSystem.build(nodes), number=iterations)
429363
print(f"{iterations} [SAFE] calls took {safe:.4f}s") # noqa: T201

tests/test_filesystem.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,7 @@ def get_path(recursive: bool) -> list[str]:
119119
assert get_path(recursive=True) == sorted(recursive_children)
120120

121121

122-
def test_unsafe_filesystem_build() -> None:
123-
dump = json.loads(TEST_FS.read_text())
124-
nodes = (Node.from_dump(node) for node in dump["nodes"].values())
125-
UserFileSystem.build_unsafe(nodes)
126-
127-
128-
def test_safe_filesystem_build() -> None:
122+
def test_filesystem_build() -> None:
129123
dump = json.loads(TEST_FS.read_text())
130124
nodes = (Node.from_dump(node) for node in dump["nodes"].values())
131125
fs = UserFileSystem.build(nodes)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)