1212from typing import TYPE_CHECKING , Any , Final , NamedTuple , Self
1313
1414from mega .data_structures import Node , NodeID , NodeType , _DictDumper
15- from mega .errors import MultipleNodesFoundError
15+ from mega .errors import MultipleNodesFoundError , ValidationError
1616
1717if 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 )
5050class _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
414350if __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
0 commit comments