Coverage for pyTooling / Filesystem / __init__.py: 48%
485 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-08 23:46 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-08 23:46 +0000
1# ==================================================================================================================== #
2# _____ _ _ _____ _ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | ___(_) | ___ ___ _ _ ___| |_ ___ _ __ ___ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |_ | | |/ _ \/ __| | | / __| __/ _ \ '_ ` _ \ #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| _| | | | __/\__ \ |_| \__ \ || __/ | | | | | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_| |_|_|\___||___/\__, |___/\__\___|_| |_| |_| #
7# |_| |___/ |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2025-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32An object-oriented file system abstraction for directory, file, symbolic link, ... statistics collection.
34.. important::
36 This isn't a replacement of :mod:`pathlib` introduced with Python 3.4.
37"""
38from os import scandir, readlink
40from enum import Enum
41from itertools import chain
42from pathlib import Path
43from typing import Optional as Nullable, Dict, Generic, Generator, TypeVar, List, Any, Callable, Union
45try:
46 from pyTooling.Decorators import readonly, export
47 from pyTooling.Exceptions import ToolingException
48 from pyTooling.MetaClasses import ExtendedType, abstractmethod
49 from pyTooling.Common import getFullyQualifiedName, zipdicts
50 from pyTooling.Stopwatch import Stopwatch
51 from pyTooling.Tree import Node
52except (ImportError, ModuleNotFoundError): # pragma: no cover
53 print("[pyTooling.Filesystem] Could not import from 'pyTooling.*'!")
55 try:
56 from pyTooling.Decorators import readonly, export
57 from pyTooling.Exceptions import ToolingException
58 from pyTooling.MetaClasses import ExtendedType, abstractmethod
59 from pyTooling.Common import getFullyQualifiedName
60 from pyTooling.Stopwatch import Stopwatch
61 from pyTooling.Tree import Node
62 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
63 print("[pyTooling.Filesystem] Could not import directly!")
64 raise ex
67__all__ = ["_ParentType"]
69_ParentType = TypeVar("_ParentType", bound="Element")
70"""The type variable for a parent reference."""
73@export
74class FilesystemException(ToolingException):
75 """Base-exception of all exceptions raised by :mod:`pyTooling.Filesystem`."""
78@export
79class NodeKind(Enum):
80 """
81 Node kind for filesystem elements in a :ref:`tree <STRUCT/Tree>`.
83 This enumeration is used when converting the filesystem statistics tree to an instance of :mod:`pyTooling.Tree`.
84 """
85 Directory = 0 #: Node represents a directory.
86 File = 1 #: Node represents a regular file.
87 SymbolicLink = 2 #: Node represents a symbolic link.
90@export
91class Base(metaclass=ExtendedType, slots=True):
92 """
93 Base-class for all filesystem elements in :mod:`pyTooling.Filesystem`.
95 It implements a size and a reference to the root element of the filesystem.
96 """
97 _root: Nullable["Root"] #: Reference to the root of the filesystem statistics scope.
98 _size: Nullable[int] #: Actual or aggregated size of the filesystem element.
100 def __init__(
101 self,
102 size: Nullable[int],
103 root: Nullable["Root"]
104 ) -> None:
105 """
106 Initialize the base-class with filesystem element size and root reference.
108 :param size: Optional size of the element.
109 :param root: Optional reference to the filesystem root element.
110 """
111 if size is None:
112 pass
113 elif not isinstance(size, int): 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true
114 ex = TypeError("Parameter 'size' is not of type 'int'.")
115 ex.add_note(f"Got type '{getFullyQualifiedName(size)}'.")
116 raise ex
118 self._size = size
119 self._root = root
121 @property
122 def Root(self) -> Nullable["Root"]:
123 """
124 Property to access the root of the filesystem statistics scope.
126 :returns: Root of the filesystem statistics scope.
127 """
128 return self._root
130 @Root.setter
131 def Root(self, value: "Root") -> None:
132 self._root = value
134 @readonly
135 def Size(self) -> int:
136 """
137 Read-only property to access the element's size in Bytes.
139 :returns: Size in Bytes.
140 :raises FilesystemException: If size is not computed, yet.
141 """
142 if self._size is None: 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true
143 raise FilesystemException("Size is not computed, yet.")
145 return self._size
147 # FIXME: @abstractmethod
148 def ToTree(self) -> Node:
149 """
150 Convert a filesystem element to a node in :mod:`pyTooling.Tree`.
152 The node's :attr:`~pyTooling.Tree.Node.Value` field contains a reference to the filesystem element. Additional data
153 will be stored in the node's key-value store.
155 :returns: A tree's node referencing this filesystem element.
156 """
157 raise NotImplementedError()
160@export
161class Element(Base, Generic[_ParentType]):
162 """
163 Base-class for all named elements within a filesystem.
165 It adds a name, parent reference and list of symbolic-link sources.
167 .. hint::
169 Symbolic link sources are reverse references describing which symbolic links point to this element.
170 """
171 _name: str #: Name of the filesystem element.
172 _parent: _ParentType #: Reference to the filesystem element's parent (:class:`Directory`)
173 _linkSources: List["SymbolicLink"] #: A list of symbolic links pointing to this filesystem element.
175 def __init__(
176 self,
177 name: str,
178 size: Nullable[int] = None,
179 parent: Nullable[_ParentType] = None
180 ) -> None:
181 """
182 Initialize the element base-class with name, size and parent reference.
184 :param name: Name of the element.
185 :param size: Optional size of the element.
186 :param parent: Optional parent reference.
187 """
188 root = None # FIXME: if parent is None else parent._root
190 super().__init__(size, root)
192 self._parent = parent
193 self._name = name
194 self._linkSources = []
196 @property
197 def Parent(self) -> _ParentType:
198 """
199 Property to access the element's parent.
201 :returns: Parent element.
202 """
203 return self._parent
205 @Parent.setter
206 def Parent(self, value: _ParentType) -> None:
207 self._parent = value
209 if value._root is not None:
210 self._root = value._root
212 @readonly
213 def Name(self) -> str:
214 """
215 Read-only property to access the element's name.
217 :returns: Element name.
218 """
219 return self._name
221 @readonly
222 def Path(self) -> Path:
223 raise NotImplemented(f"Property 'Path' is abstract.")
225 def AddLinkSources(self, source: "SymbolicLink") -> None:
226 """
227 Add a link source of a symbolic link to the named element (reverse reference).
229 :param source: The referenced symbolic link.
230 """
231 if not isinstance(source, SymbolicLink):
232 ex = TypeError("Parameter 'source' is not of type 'SymbolicLink'.")
233 ex.add_note(f"Got type '{getFullyQualifiedName(source)}'.")
234 raise ex
236 self._linkSources.append(source)
239@export
240class Directory(Element["Directory"]):
241 """
242 A **directory** represents a directory in the filesystem, which contains subdirectories, regular files and symbolic links.
244 While scanning for subelements, the directory is populated with elements. Every file object added, gets registered in
245 the filesystems :class:`Root` for deduplication. In case a file identifier already exists, the found filename will
246 reference the same file objects. In turn, the file objects has then references to multiple filenames (parents). This
247 allows to detect :term:`hardlinks <hardlink>`.
249 The time needed for scanning the directory and its subelements is provided via :data:`ScanDuration`.
251 After scnaning the directory for subelements, certain directory properties get aggregated. The time needed for
252 aggregation is provided via :data:`AggregateDuration`.
253 """
255 _path: Nullable[Path] #: Cached :class:`~pathlib.Path` object of this directory.
256 _subdirectories: Dict[str, "Directory"] #: Dictionary containing name-:class:`Directory` pairs.
257 _files: Dict[str, "Filename"] #: Dictionary containing name-:class:`Filename` pairs.
258 _symbolicLinks: Dict[str, "SymbolicLink"] #: Dictionary containing name-:class:`SymbolicLink` pairs.
259 _collapsed: bool #: True, if this directory was collapsed. It contains no subelements.
260 _scanDuration: Nullable[float] #: Duration for scanning the directory and all its subelements.
261 _aggregateDuration: Nullable[float] #: Duration for aggregating all subelements.
263 def __init__(
264 self,
265 name: str,
266 collectSubdirectories: bool = False,
267 parent: Nullable["Directory"] = None
268 ) -> None:
269 """
270 Initialize the directory with name and parent reference.
272 :param name: Name of the element.
273 :param collectSubdirectories: If true, collect subdirectory statistics.
274 :param parent: Optional parent reference.
275 """
276 super().__init__(name, None, parent)
278 self._path = None
279 self._subdirectories = {}
280 self._files = {}
281 self._symbolicLinks = {}
282 self._collapsed = False
283 self._scanDuration = None
284 self._aggregateDuration = None
286 if parent is not None:
287 parent._subdirectories[name] = self
289 if parent._root is not None: 289 ↛ 292line 289 didn't jump to line 292 because the condition on line 289 was always true
290 self._root = parent._root
292 if collectSubdirectories: 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true
293 self._collectSubdirectories()
295 def _collectSubdirectories(self) -> None:
296 """
297 Helper method for scanning subdirectories and aggregating found element sizes therein.
298 """
299 with Stopwatch() as sw1:
300 self._scanSubdirectories()
302 with Stopwatch() as sw2:
303 self._aggregateSizes()
305 self._scanDuration = sw1.Duration
306 self._aggregateDuration = sw2.Duration
308 def _scanSubdirectories(self) -> None:
309 """
310 Helper method for scanning subdirectories (recursively) and building a
311 :class:`Directory`-:class:`Filename`-:class:`File` object tree.
313 If a file refers to the same filesystem internal unique ID, a hardlink (two or more filenames) to the same file
314 storage object is assumed.
315 """
316 try:
317 items = scandir(directoryPath := self.Path)
318 except PermissionError as ex:
319 return
321 for dirEntry in items:
322 if dirEntry.is_dir(follow_symlinks=False):
323 subdirectory = Directory(dirEntry.name, collectSubdirectories=True, parent=self)
324 elif dirEntry.is_file(follow_symlinks=False):
325 id = dirEntry.inode()
326 if id in self._root._ids:
327 file = self._root._ids[id]
329 hardLink = Filename(dirEntry.name, file=file, parent=self)
330 else:
331 s = dirEntry.stat(follow_symlinks=False)
332 filename = Filename(dirEntry.name, parent=self)
333 file = File(id, s.st_size, parent=filename)
335 self._root._ids[id] = file
336 elif dirEntry.is_symlink():
337 target = Path(readlink(directoryPath / dirEntry.name))
338 symlink = SymbolicLink(dirEntry.name, target, parent=self)
339 else:
340 raise FilesystemException(f"Unknown directory element.")
342 def _connectSymbolicLinks(self) -> None:
343 for dir in self._subdirectories.values():
344 dir._connectSymbolicLinks()
346 for link in self._symbolicLinks.values():
347 if link._target.is_absolute():
348 pass
349 else:
350 target = self
351 for elem in link._target.parts:
352 if elem == ".":
353 continue
354 elif elem == "..":
355 target = target._parent
356 continue
358 try:
359 target = target._subdirectories[elem]
360 continue
361 except KeyError:
362 pass
364 try:
365 target = target._files[elem]
366 continue
367 except KeyError:
368 pass
370 try:
371 target = target._symbolicLinks[elem]
372 continue
373 except KeyError:
374 pass
376 target.AddLinkSources(link)
378 def _aggregateSizes(self) -> None:
379 self._size = (
380 sum(dir._size for dir in self._subdirectories.values()) +
381 sum(file._file._size for file in self._files.values())
382 )
384 @Element.Root.setter
385 def Root(self, value: "Root") -> None:
386 Element.Root.fset(self, value)
388 for subdir in self._subdirectories.values(): 388 ↛ 389line 388 didn't jump to line 389 because the loop on line 388 never started
389 subdir.Root = value
391 for file in self._files.values():
392 file.Root = value
394 for link in self._symbolicLinks.values(): 394 ↛ 395line 394 didn't jump to line 395 because the loop on line 394 never started
395 link.Root = value
397 @Element.Parent.setter
398 def Parent(self, value: _ParentType) -> None:
399 Element.Parent.fset(self, value)
401 value._subdirectories[self._name] = self
403 if isinstance(value, Root): 403 ↛ exitline 403 didn't return from function 'Parent' because the condition on line 403 was always true
404 self.Root = value
406 @readonly
407 def Count(self) -> int:
408 """
409 Read-only property to access the number of elements in a directory.
411 :returns: Number of files plus subdirectories.
412 """
413 return len(self._subdirectories) + len(self._files) + len(self._symbolicLinks)
415 @readonly
416 def FileCount(self) -> int:
417 """
418 Read-only property to access the number of files in a directory.
420 .. hint::
422 Files include regular files and symbolic links.
424 :returns: Number of files.
425 """
426 return len(self._files) + len(self._symbolicLinks)
428 @readonly
429 def RegularFileCount(self) -> int:
430 """
431 Read-only property to access the number of regular files in a directory.
433 :returns: Number of regular files.
434 """
435 return len(self._files)
437 @readonly
438 def SymbolicLinkCount(self) -> int:
439 """
440 Read-only property to access the number of symbolic links in a directory.
442 :returns: Number of symbolic links.
443 """
444 return len(self._symbolicLinks)
446 @readonly
447 def SubdirectoryCount(self) -> int:
448 """
449 Read-only property to access the number of subdirectories in a directory.
451 :returns: Number of subdirectories.
452 """
453 return len(self._subdirectories)
455 @readonly
456 def TotalFileCount(self) -> int:
457 """
458 Read-only property to access the total number of files in all child hierarchy levels (recursively).
460 .. hint::
462 Files include regular files and symbolic links.
464 :returns: Total number of files.
465 """
466 return sum(d.TotalFileCount for d in self._subdirectories.values()) + len(self._files) + len(self._symbolicLinks)
468 @readonly
469 def TotalRegularFileCount(self) -> int:
470 """
471 Read-only property to access the total number of regular files in all child hierarchy levels (recursively).
473 :returns: Total number of regular files.
474 """
475 return sum(d.TotalRegularFileCount for d in self._subdirectories.values()) + len(self._files)
477 @readonly
478 def TotalSymbolicLinkCount(self) -> int:
479 """
480 Read-only property to access the total number of symbolic links in all child hierarchy levels (recursively).
482 :returns: Total number of symbolic links.
483 """
484 return sum(d.TotalSymbolicLinkCount for d in self._subdirectories.values()) + len(self._symbolicLinks)
486 @readonly
487 def TotalSubdirectoryCount(self) -> int:
488 """
489 Read-only property to access the total number of subdirectories in all child hierarchy levels (recursively).
491 :returns: Total number of subdirectories.
492 """
493 return len(self._subdirectories) + sum(d.TotalSubdirectoryCount for d in self._subdirectories.values())
495 @readonly
496 def Subdirectories(self) -> Generator["Directory", None, None]:
497 """
498 Iterate all direct subdirectories of the directory.
500 :returns: A generator to iterate all direct subdirectories.
501 """
502 return (d for d in self._subdirectories.values())
504 @readonly
505 def Files(self) -> Generator[Union["Filename", "SymbolicLink"], None, None]:
506 """
507 Iterate all direct files of the directory.
509 .. hint::
511 Files include regular files and symbolic links.
513 :returns: A generator to iterate all direct files.
514 """
515 return (f for f in chain(self._files.values(), self._symbolicLinks.values()))
517 @readonly
518 def RegularFiles(self) -> Generator["Filename", None, None]:
519 """
520 Iterate all direct regular files of the directory.
522 :returns: A generator to iterate all direct regular files.
523 """
524 return (f for f in self._files.values())
526 @readonly
527 def SymbolicLinks(self) -> Generator["SymbolicLink", None, None]:
528 """
529 Iterate all direct symbolic links of the directory.
531 :returns: A generator to iterate all direct symbolic links.
532 """
533 return (l for l in self._symbolicLinks.values())
535 @readonly
536 def Path(self) -> Path:
537 """
538 Read-only property to access the equivalent Path instance for accessing the represented directory.
540 :returns: Path to the directory.
541 :raises FilesystemException: If no parent is set.
542 """
543 if self._path is not None:
544 return self._path
546 if self._parent is None:
547 raise FilesystemException(f"No parent or root set for directory.")
549 self._path = self._parent.Path / self._name
550 return self._path
552 @readonly
553 def ScanDuration(self) -> float:
554 """
555 Read-only property to access the time needed to scan a directory structure including all subelements (recursively).
557 :returns: The scan duration in seconds.
558 :raises FilesystemException: If the directory was not scanned.
559 """
560 if self._scanDuration is None:
561 raise FilesystemException(f"Directory was not scanned, yet.")
563 return self._scanDuration
565 @readonly
566 def AggregateDuration(self) -> float:
567 """
568 Read-only property to access the time needed to aggregate the directory's and subelement's properties (recursively).
570 :returns: The aggregation duration in seconds.
571 :raises FilesystemException: If the directory properties were not aggregated.
572 """
573 if self._scanDuration is None:
574 raise FilesystemException(f"Directory properties were not aggregated, yet.")
576 return self._aggregateDuration
578 def Copy(self, parent: Nullable["Directory"] = None) -> "Directory":
579 """
580 Copy the directory structure including all subelements and link it to the given parent.
582 .. hint::
584 Statistics like aggregated directory size are copied too. |br|
585 There is no rescan or repeated aggregation needed.
587 :param parent: The parent element of the copied directory.
588 :returns: A deep copy of the directory structure.
589 """
590 dir = Directory(self._name, parent=parent)
591 dir._size = self._size
593 for subdir in self._subdirectories.values():
594 subdir.Copy(dir)
596 for file in self._files.values():
597 file.Copy(dir)
599 for link in self._symbolicLinks.values():
600 link.Copy(dir)
602 return dir
604 def Collapse(self, func: Callable[["Directory"], bool]) -> bool:
605 # if len(self._subdirectories) == 0 or all(subdir.Collapse(func) for subdir in self._subdirectories.values()):
606 if len(self._subdirectories) == 0:
607 if func(self):
608 # print(f"collapse 1 {self.Path}")
609 self._collapsed = True
610 self._subdirectories.clear()
611 self._files.clear()
612 self._symbolicLinks.clear()
614 return True
615 else:
616 return False
618 # if all(subdir.Collapse(func) for subdir in self._subdirectories.values())
619 collapsible = True
620 for subdir in self._subdirectories.values():
621 result = subdir.Collapse(func)
622 collapsible = collapsible and result
624 if collapsible:
625 # print(f"collapse 2 {self.Path}")
626 self._collapsed = True
627 self._subdirectories.clear()
628 self._files.clear()
629 self._symbolicLinks.clear()
631 return True
632 else:
633 return False
635 def ToTree(self, format: Nullable[Callable[[Node], str]] = None) -> Node:
636 """
637 Convert the directory to a :class:`~pyTooling.Tree.Node`.
639 The node's :attr:`~pyTooling.Tree.Node.Value` field contains a reference to the directory. Additional data is
640 attached to the node's key-value store:
642 ``kind``
643 The node's kind. See :class:`NodeKind`.
644 ``size``
645 The directory's aggregated size.
647 :param format: A user defined formatting function for tree nodes.
648 :returns: A tree node representing this directory.
649 """
650 if format is None:
651 def format(node: Node) -> str:
652 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
654 directoryNode = Node(
655 value=self,
656 keyValuePairs={
657 "kind": NodeKind.File,
658 "size": self._size
659 },
660 format=format
661 )
662 directoryNode.AddChildren(
663 e.ToTree(format) for e in chain(self._subdirectories.values()) #, self._files.values(), self._symbolicLinks.values())
664 )
666 return directoryNode
668 def __eq__(self, other) -> bool:
669 """
670 Compare two Directory instances for equality.
672 :param other: Parameter to compare against.
673 :returns: ``True``, if both directories and all its subelements are equal.
674 :raises TypeError: If parameter ``other`` is not of type :class:`Directory`.
675 """
676 if not isinstance(other, Directory):
677 ex = TypeError("Parameter 'other' is not of type Directory.")
678 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
679 raise ex
681 if not all(dir1 == dir2 for _, dir1, dir2 in zipdicts(self._subdirectories, other._subdirectories)):
682 return False
684 if not all(file1 == file2 for _, file1, file2 in zipdicts(self._files, other._files)):
685 return False
687 if not all(link1 == link2 for _, link1, link2 in zipdicts(self._symbolicLinks, other._symbolicLinks)):
688 return False
690 return True
692 def __ne__(self, other: Any) -> bool:
693 """
694 Compare two Directory instances for inequality.
696 :param other: Parameter to compare against.
697 :returns: ``True``, if both directories and all its subelements are unequal.
698 :raises TypeError: If parameter ``other`` is not of type :class:`Directory`.
699 """
700 return not self.__eq__(other)
702 def __repr__(self) -> str:
703 return f"Directory: {self.Path}"
705 def __str__(self) -> str:
706 return self._name
709@export
710class Filename(Element[Directory]):
711 """
712 Represents a filename in the filesystem, but not the file storage object (:class:`File`).
714 .. hint::
716 Filename and file storage are represented by two classes, which allows multiple names (hard links) per file storage
717 object.
718 """
719 _file: Nullable["File"]
721 def __init__(
722 self,
723 name: str,
724 file: Nullable["File"] = None,
725 parent: Nullable[Directory] = None
726 ) -> None:
727 """
728 Initialize the filename with name, file (storage) object and parent reference.
730 :param name: Name of the file.
731 :param size: Optional file (storage) object.
732 :param parent: Optional parent reference.
733 """
734 super().__init__(name, None, parent)
736 if file is None: 736 ↛ 739line 736 didn't jump to line 739 because the condition on line 736 was always true
737 self._file = None
738 else:
739 self._file = file
740 file._parents.append(self)
742 if parent is not None:
743 parent._files[name] = self
745 if parent._root is not None:
746 self._root = parent._root
748 @Element.Root.setter
749 def Root(self, value: "Root") -> None:
750 self._root = value
752 if self._file is not None: 752 ↛ exitline 752 didn't return from function 'Root' because the condition on line 752 was always true
753 self._file._root = value
755 @Element.Parent.setter
756 def Parent(self, value: _ParentType) -> None:
757 Element.Parent.fset(self, value)
759 value._files[self._name] = self
761 if isinstance(value, Root): 761 ↛ 762line 761 didn't jump to line 762 because the condition on line 761 was never true
762 self.Root = value
764 @readonly
765 def File(self) -> Nullable["File"]:
766 return self._file
768 @readonly
769 def Size(self) -> int:
770 if self._file is None:
771 raise ToolingException(f"Filename isn't linked to a File object.")
773 return self._file._size
775 @readonly
776 def Path(self) -> Path:
777 if self._parent is None:
778 raise ToolingException(f"Filename has no parent object.")
780 return self._parent.Path / self._name
782 def Copy(self, parent: Directory) -> "Filename":
783 fileID = self._file._id
785 if fileID in parent._root._ids:
786 file = parent._root._ids[fileID]
787 else:
788 fileSize = self._file._size
789 file = File(fileID, fileSize)
791 parent._root._ids[fileID] = file
793 return Filename(self._name, file, parent=parent)
795 def ToTree(self) -> Node:
796 def format(node: Node) -> str:
797 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
799 fileNode = Node(
800 value=self,
801 keyValuePairs={
802 "kind": NodeKind.File,
803 "size": self._size
804 },
805 format=format
806 )
808 return fileNode
810 def __eq__(self, other) -> bool:
811 """
812 Compare two Filename instances for equality.
814 :param other: Parameter to compare against.
815 :returns: ``True``, if both filenames are equal.
816 :raises TypeError: If parameter ``other`` is not of type :class:`Filename`.
817 """
818 if not isinstance(other, Filename):
819 ex = TypeError("Parameter 'other' is not of type Filename.")
820 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
821 raise ex
823 return self._name == other._name and self.Size == other.Size
825 def __ne__(self, other: Any) -> bool:
826 """
827 Compare two Filename instances for inequality.
829 :param other: Parameter to compare against.
830 :returns: ``True``, if both filenames are unequal.
831 :raises TypeError: If parameter ``other`` is not of type :class:`Filename`.
832 """
833 if not isinstance(other, Filename):
834 ex = TypeError("Parameter 'other' is not of type Filename.")
835 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
836 raise ex
838 return self._name != other._name or self.Size != other.Size
840 def __repr__(self) -> str:
841 return f"File: {self.Path}"
843 def __str__(self) -> str:
844 return self._name
847@export
848class SymbolicLink(Element[Directory]):
849 _target: Path
851 def __init__(
852 self,
853 name: str,
854 target: Path,
855 parent: Nullable[Directory]
856 ) -> None:
857 super().__init__(name, None, parent)
859 self._target = target
861 if parent is not None:
862 parent._symbolicLinks[name] = self
864 if parent._root is not None:
865 self._root = parent._root
867 @readonly
868 def Path(self) -> Path:
869 return self._parent.Path / self._name
871 @readonly
872 def Target(self) -> Path:
873 return self._target
875 def Copy(self, parent: Directory) -> "SymbolicLink":
876 return SymbolicLink(self._name, self._target, parent=parent)
878 def ToTree(self) -> Node:
879 def format(node: Node) -> str:
880 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
882 symbolicLinkNode = Node(
883 value=self,
884 keyValuePairs={
885 "kind": NodeKind.SymbolicLink,
886 "size": self._size
887 },
888 format=format
889 )
891 return symbolicLinkNode
893 def __eq__(self, other) -> bool:
894 """
895 Compare two SymbolicLink instances for equality.
897 :param other: Parameter to compare against.
898 :returns: ``True``, if both symbolic links are equal.
899 :raises TypeError: If parameter ``other`` is not of type :class:`SymbolicLink`.
900 """
901 if not isinstance(other, SymbolicLink):
902 ex = TypeError("Parameter 'other' is not of type SymbolicLink.")
903 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
904 raise ex
906 return self._name == other._name and self._target == other._target
908 def __ne__(self, other: Any) -> bool:
909 """
910 Compare two SymbolicLink instances for inequality.
912 :param other: Parameter to compare against.
913 :returns: ``True``, if both symbolic links are unequal.
914 :raises TypeError: If parameter ``other`` is not of type :class:`SymbolicLink`.
915 """
916 if not isinstance(other, SymbolicLink):
917 ex = TypeError("Parameter 'other' is not of type SymbolicLink.")
918 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
919 raise ex
921 return self._name != other._name or self._target != other._target
923 def __repr__(self) -> str:
924 return f"SymLink: {self.Path} -> {self._target}"
926 def __str__(self) -> str:
927 return self._name
930@export
931class Root(Directory):
932 """
933 A **Root** represents the root-directory in the filesystem, which contains subdirectories, regular files and symbolic links.
934 """
935 _ids: Dict[int, "File"] #: Dictionary of file identifier - file objects pairs found while scanning the directory structure.
937 def __init__(
938 self,
939 rootDirectory: Path,
940 collectSubdirectories: bool = True
941 ) -> None:
942 if rootDirectory is None: 942 ↛ 943line 942 didn't jump to line 943 because the condition on line 942 was never true
943 raise ValueError(f"Parameter 'rootDirectory' is None.")
944 elif not isinstance(rootDirectory, Path): 944 ↛ 945line 944 didn't jump to line 945 because the condition on line 944 was never true
945 raise TypeError(f"Parameter 'rootDirectory' is not of type 'Path'.")
946 elif not rootDirectory.exists(): 946 ↛ 947line 946 didn't jump to line 947 because the condition on line 946 was never true
947 raise ToolingException(f"Path '{rootDirectory}' doesn't exist.") from FileNotFoundError(rootDirectory)
949 self._ids = {}
951 super().__init__(rootDirectory.name)
952 self._root = self
953 self._path = rootDirectory
955 if collectSubdirectories: 955 ↛ 956line 955 didn't jump to line 956 because the condition on line 955 was never true
956 self._collectSubdirectories()
957 self._connectSymbolicLinks()
959 @readonly
960 def TotalHardLinkCount(self) -> int:
961 return sum(l for f in self._ids.values() if (l := len(f._parents)) > 1)
963 @readonly
964 def TotalHardLinkCount2(self) -> int:
965 return sum(1 for f in self._ids.values() if len(f._parents) > 1)
967 @readonly
968 def TotalHardLinkCount3(self) -> int:
969 return sum(1 for f in self._ids.values() if len(f._parents) == 1)
971 @readonly
972 def Size2(self) -> int:
973 return sum(f._size for f in self._ids.values() if len(f._parents) > 1)
975 @readonly
976 def Size3(self) -> int:
977 return sum(f._size * len(f._parents) for f in self._ids.values() if len(f._parents) > 1)
979 @readonly
980 def TotalUniqueFileCount(self) -> int:
981 return len(self._ids)
983 @readonly
984 def Path(self) -> Path:
985 """
986 Read-only property to access the path of the filesystem statistics root.
988 :returns: Path to the root of the filesystem statistics root directory.
989 """
990 return self._path
992 def Copy(self) -> "Root":
993 """
994 Copy the directory structure including all subelements and link it to the given parent.
996 The duration for the deep copy process is provided in :attr:`ScanDuration`
998 .. hint::
1000 Statistics like aggregated directory size are copied too. |br|
1001 There is no rescan or repeated aggregation needed.
1003 :returns: A deep copy of the directory structure.
1004 """
1005 with Stopwatch() as sw:
1006 root = Root(self._path, False)
1007 root._size = self._size
1009 for subdir in self._subdirectories.values():
1010 subdir.Copy(root)
1012 for file in self._files.values():
1013 file.Copy(root)
1015 for link in self._symbolicLinks.values():
1016 link.Copy(root)
1018 root._scanDuration = sw.Duration
1019 root._aggregateDuration = 0.0
1021 return root
1023 def __repr__(self) -> str:
1024 return f"Root: {self.Path} (dirs: {self.TotalSubdirectoryCount}, files: {self.TotalRegularFileCount}, symlinks: {self.TotalSymbolicLinkCount})"
1026 def __str__(self) -> str:
1027 return self._name
1030@export
1031class File(Base):
1032 """
1033 A **File** represents a file storage object in the filesystem, which is accessible by one or more :class:`Filename` objects.
1035 Each file has an internal id, which is associated to a unique ID within the host's filesystem.
1036 """
1037 _id: int #: Unique (host internal) file object ID)
1038 _parents: List[Filename] #: List of reverse references to :class:`Filename` objects.
1040 def __init__(
1041 self,
1042 id: int,
1043 size: int,
1044 parent: Nullable[Filename] = None
1045 ) -> None:
1046 """
1047 Initialize the File storage object with an ID, size and parent reference.
1049 :param id: Unique ID of the file object.
1050 :param size: Size of the file object.
1051 :param parent: Optional parent reference.
1052 """
1053 if not isinstance(id, int): 1053 ↛ 1054line 1053 didn't jump to line 1054 because the condition on line 1053 was never true
1054 ex = TypeError("Parameter 'id' is not of type 'int'.")
1055 ex.add_note(f"Got type '{getFullyQualifiedName(id)}'.")
1056 raise ex
1058 self._id = id
1060 if parent is None:
1061 super().__init__(size, None)
1062 self._parents = []
1063 elif isinstance(parent, Filename): 1063 ↛ 1068line 1063 didn't jump to line 1068 because the condition on line 1063 was always true
1064 super().__init__(size, parent._root)
1065 self._parents = [parent]
1066 parent._file = self
1067 else:
1068 ex = TypeError("Parameter 'parent' is not of type 'Filename'.")
1069 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
1070 raise ex
1072 @readonly
1073 def ID(self) -> int:
1074 """
1075 Read-only property to access the file object's unique identifier.
1077 :returns: Unique file object identifier.
1078 """
1079 return self._id
1081 @readonly
1082 def Parents(self) -> List[Filename]:
1083 """
1084 Read-only property to access the list of filenames using the same file storage object.
1086 .. hint::
1088 This allows to check if a file object has multiple filenames a.k.a hardlinks.
1090 :returns: List of filenames for the file storage object.
1091 """
1092 return self._parents
1094 def AddParent(self, file: Filename) -> None:
1095 """
1096 Add another parent reference to a :class:`Filename`.
1098 :param file: Reference to a filename object.
1099 """
1100 if not isinstance(file, Filename): 1100 ↛ 1101line 1100 didn't jump to line 1101 because the condition on line 1100 was never true
1101 ex = TypeError("Parameter 'file' is not of type 'Filename'.")
1102 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.")
1103 raise ex
1104 elif file._file is not None: 1104 ↛ 1105line 1104 didn't jump to line 1105 because the condition on line 1104 was never true
1105 raise ToolingException(f"Filename is already referencing an other file object ({file._file._id}).")
1107 self._parents.append(file)
1108 file._file = self
1110 if file._root is not None:
1111 self._root = file._root