Coverage for pyTooling / Filesystem / __init__.py: 48%
484 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-07 17:18 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-07 17:18 +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
45from pyTooling.Decorators import readonly, export
46from pyTooling.Exceptions import ToolingException
47from pyTooling.MetaClasses import ExtendedType
48from pyTooling.Common import getFullyQualifiedName, zipdicts
49from pyTooling.Stopwatch import Stopwatch
50from pyTooling.Tree import Node
53__all__ = ["_ParentType"]
55_ParentType = TypeVar("_ParentType", bound="Element")
56"""The type variable for a parent reference."""
59@export
60class FilesystemException(ToolingException):
61 """Base-exception of all exceptions raised by :mod:`pyTooling.Filesystem`."""
64@export
65class NodeKind(Enum):
66 """
67 Node kind for filesystem elements in a :ref:`tree <STRUCT/Tree>`.
69 This enumeration is used when converting the filesystem statistics tree to an instance of :mod:`pyTooling.Tree`.
70 """
71 Directory = 0 #: Node represents a directory.
72 File = 1 #: Node represents a regular file.
73 SymbolicLink = 2 #: Node represents a symbolic link.
76@export
77class Base(metaclass=ExtendedType, slots=True):
78 """
79 Base-class for all filesystem elements in :mod:`pyTooling.Filesystem`.
81 It implements a size and a reference to the root element of the filesystem.
82 """
83 _root: Nullable["Root"] #: Reference to the root of the filesystem statistics scope.
84 _size: Nullable[int] #: Actual or aggregated size of the filesystem element.
86 def __init__(
87 self,
88 size: Nullable[int],
89 root: Nullable["Root"]
90 ) -> None:
91 """
92 Initialize the base-class with filesystem element size and root reference.
94 :param size: Optional size of the element.
95 :param root: Optional reference to the filesystem root element.
96 """
97 if size is None:
98 pass
99 elif not isinstance(size, int): 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 ex = TypeError("Parameter 'size' is not of type 'int'.")
101 ex.add_note(f"Got type '{getFullyQualifiedName(size)}'.")
102 raise ex
104 self._size = size
105 self._root = root
107 @property
108 def Root(self) -> Nullable["Root"]:
109 """
110 Property to access the root of the filesystem statistics scope.
112 :returns: Root of the filesystem statistics scope.
113 """
114 return self._root
116 @Root.setter
117 def Root(self, value: "Root") -> None:
118 self._root = value
120 @readonly
121 def Size(self) -> int:
122 """
123 Read-only property to access the element's size in Bytes.
125 :returns: Size in Bytes.
126 :raises FilesystemException: If size is not computed, yet.
127 """
128 if self._size is None: 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true
129 raise FilesystemException("Size is not computed, yet.")
131 return self._size
133 # FIXME: @abstractmethod
134 def ToTree(self) -> Node:
135 """
136 Convert a filesystem element to a node in :mod:`pyTooling.Tree`.
138 The node's :attr:`~pyTooling.Tree.Node.Value` field contains a reference to the filesystem element. Additional data
139 will be stored in the node's key-value store.
141 :returns: A tree's node referencing this filesystem element.
142 """
143 raise NotImplementedError()
146@export
147class Element(Base, Generic[_ParentType]):
148 """
149 Base-class for all named elements within a filesystem.
151 It adds a name, parent reference and list of symbolic-link sources.
153 .. hint::
155 Symbolic link sources are reverse references describing which symbolic links point to this element.
156 """
157 _name: str #: Name of the filesystem element.
158 _parent: _ParentType #: Reference to the filesystem element's parent (:class:`Directory`)
159 _linkSources: List["SymbolicLink"] #: A list of symbolic links pointing to this filesystem element.
161 def __init__(
162 self,
163 name: str,
164 size: Nullable[int] = None,
165 parent: Nullable[_ParentType] = None
166 ) -> None:
167 """
168 Initialize the element base-class with name, size and parent reference.
170 :param name: Name of the element.
171 :param size: Optional size of the element.
172 :param parent: Optional parent reference.
173 """
174 root = None # FIXME: if parent is None else parent._root
176 super().__init__(size, root)
178 self._parent = parent
179 self._name = name
180 self._linkSources = []
182 @property
183 def Parent(self) -> _ParentType:
184 """
185 Property to access the element's parent.
187 :returns: Parent element.
188 """
189 return self._parent
191 @Parent.setter
192 def Parent(self, value: _ParentType) -> None:
193 self._parent = value
195 if value._root is not None:
196 self._root = value._root
198 @readonly
199 def Name(self) -> str:
200 """
201 Read-only property to access the element's name.
203 :returns: Element name.
204 """
205 return self._name
207 @readonly
208 def Path(self) -> Path:
209 raise NotImplemented(f"Property 'Path' is abstract.")
211 def AddLinkSources(self, source: "SymbolicLink") -> None:
212 """
213 Add a link source of a symbolic link to the named element (reverse reference).
215 :param source: The referenced symbolic link.
216 """
217 if not isinstance(source, SymbolicLink):
218 ex = TypeError("Parameter 'source' is not of type 'SymbolicLink'.")
219 ex.add_note(f"Got type '{getFullyQualifiedName(source)}'.")
220 raise ex
222 self._linkSources.append(source)
225@export
226class Directory(Element["Directory"]):
227 """
228 A **directory** represents a directory in the filesystem, which contains subdirectories, regular files and symbolic links.
230 While scanning for subelements, the directory is populated with elements. Every file object added, gets registered in
231 the filesystems :class:`Root` for deduplication. In case a file identifier already exists, the found filename will
232 reference the same file objects. In turn, the file objects has then references to multiple filenames (parents). This
233 allows to detect :term:`hardlinks <hardlink>`.
235 The time needed for scanning the directory and its subelements is provided via :data:`ScanDuration`.
237 After scnaning the directory for subelements, certain directory properties get aggregated. The time needed for
238 aggregation is provided via :data:`AggregateDuration`.
239 """
241 _path: Nullable[Path] #: Cached :class:`~pathlib.Path` object of this directory.
242 _subdirectories: Dict[str, "Directory"] #: Dictionary containing name-:class:`Directory` pairs.
243 _files: Dict[str, "Filename"] #: Dictionary containing name-:class:`Filename` pairs.
244 _symbolicLinks: Dict[str, "SymbolicLink"] #: Dictionary containing name-:class:`SymbolicLink` pairs.
245 _collapsed: bool #: True, if this directory was collapsed. It contains no subelements.
246 _scanDuration: Nullable[float] #: Duration for scanning the directory and all its subelements.
247 _aggregateDuration: Nullable[float] #: Duration for aggregating all subelements.
249 def __init__(
250 self,
251 name: str,
252 collectSubdirectories: bool = False,
253 parent: Nullable["Directory"] = None
254 ) -> None:
255 """
256 Initialize the directory with name and parent reference.
258 :param name: Name of the element.
259 :param collectSubdirectories: If true, collect subdirectory statistics.
260 :param parent: Optional parent reference.
261 """
262 super().__init__(name, None, parent)
264 self._path = None
265 self._subdirectories = {}
266 self._files = {}
267 self._symbolicLinks = {}
268 self._collapsed = False
269 self._scanDuration = None
270 self._aggregateDuration = None
272 if parent is not None:
273 parent._subdirectories[name] = self
275 if parent._root is not None: 275 ↛ 278line 275 didn't jump to line 278 because the condition on line 275 was always true
276 self._root = parent._root
278 if collectSubdirectories: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 self._collectSubdirectories()
281 def _collectSubdirectories(self) -> None:
282 """
283 Helper method for scanning subdirectories and aggregating found element sizes therein.
284 """
285 with Stopwatch() as sw1:
286 self._scanSubdirectories()
288 with Stopwatch() as sw2:
289 self._aggregateSizes()
291 self._scanDuration = sw1.Duration
292 self._aggregateDuration = sw2.Duration
294 def _scanSubdirectories(self) -> None:
295 """
296 Helper method for scanning subdirectories (recursively) and building a
297 :class:`Directory`-:class:`Filename`-:class:`File` object tree.
299 If a file refers to the same filesystem internal unique ID, a hardlink (two or more filenames) to the same file
300 storage object is assumed.
301 """
302 try:
303 items = scandir(directoryPath := self.Path)
304 except PermissionError as ex:
305 return
307 for dirEntry in items:
308 if dirEntry.is_dir(follow_symlinks=False):
309 subdirectory = Directory(dirEntry.name, collectSubdirectories=True, parent=self)
310 elif dirEntry.is_file(follow_symlinks=False):
311 id = dirEntry.inode()
312 if id in self._root._ids:
313 file = self._root._ids[id]
315 hardLink = Filename(dirEntry.name, file=file, parent=self)
316 else:
317 s = dirEntry.stat(follow_symlinks=False)
318 filename = Filename(dirEntry.name, parent=self)
319 file = File(id, s.st_size, parent=filename)
321 self._root._ids[id] = file
322 elif dirEntry.is_symlink():
323 target = Path(readlink(directoryPath / dirEntry.name))
324 symlink = SymbolicLink(dirEntry.name, target, parent=self)
325 else:
326 raise FilesystemException(f"Unknown directory element.")
328 def _connectSymbolicLinks(self) -> None:
329 for dir in self._subdirectories.values():
330 dir._connectSymbolicLinks()
332 for link in self._symbolicLinks.values():
333 if link._target.is_absolute():
334 pass
335 else:
336 target = self
337 for elem in link._target.parts:
338 if elem == ".":
339 continue
340 elif elem == "..":
341 target = target._parent
342 continue
344 try:
345 target = target._subdirectories[elem]
346 continue
347 except KeyError:
348 pass
350 try:
351 target = target._files[elem]
352 continue
353 except KeyError:
354 pass
356 try:
357 target = target._symbolicLinks[elem]
358 continue
359 except KeyError:
360 pass
362 target.AddLinkSources(link)
364 def _aggregateSizes(self) -> None:
365 self._size = (
366 sum(dir._size for dir in self._subdirectories.values()) +
367 sum(file._file._size for file in self._files.values())
368 )
370 @Element.Root.setter
371 def Root(self, value: "Root") -> None:
372 Element.Root.fset(self, value)
374 for subdir in self._subdirectories.values(): 374 ↛ 375line 374 didn't jump to line 375 because the loop on line 374 never started
375 subdir.Root = value
377 for file in self._files.values():
378 file.Root = value
380 for link in self._symbolicLinks.values(): 380 ↛ 381line 380 didn't jump to line 381 because the loop on line 380 never started
381 link.Root = value
383 @Element.Parent.setter
384 def Parent(self, value: _ParentType) -> None:
385 Element.Parent.fset(self, value)
387 value._subdirectories[self._name] = self
389 if isinstance(value, Root): 389 ↛ exitline 389 didn't return from function 'Parent' because the condition on line 389 was always true
390 self.Root = value
392 @readonly
393 def Count(self) -> int:
394 """
395 Read-only property to access the number of elements in a directory.
397 :returns: Number of files plus subdirectories.
398 """
399 return len(self._subdirectories) + len(self._files) + len(self._symbolicLinks)
401 @readonly
402 def FileCount(self) -> int:
403 """
404 Read-only property to access the number of files in a directory.
406 .. hint::
408 Files include regular files and symbolic links.
410 :returns: Number of files.
411 """
412 return len(self._files) + len(self._symbolicLinks)
414 @readonly
415 def RegularFileCount(self) -> int:
416 """
417 Read-only property to access the number of regular files in a directory.
419 :returns: Number of regular files.
420 """
421 return len(self._files)
423 @readonly
424 def SymbolicLinkCount(self) -> int:
425 """
426 Read-only property to access the number of symbolic links in a directory.
428 :returns: Number of symbolic links.
429 """
430 return len(self._symbolicLinks)
432 @readonly
433 def SubdirectoryCount(self) -> int:
434 """
435 Read-only property to access the number of subdirectories in a directory.
437 :returns: Number of subdirectories.
438 """
439 return len(self._subdirectories)
441 @readonly
442 def TotalFileCount(self) -> int:
443 """
444 Read-only property to access the total number of files in all child hierarchy levels (recursively).
446 .. hint::
448 Files include regular files and symbolic links.
450 :returns: Total number of files.
451 """
452 return sum(d.TotalFileCount for d in self._subdirectories.values()) + len(self._files) + len(self._symbolicLinks)
454 @readonly
455 def TotalRegularFileCount(self) -> int:
456 """
457 Read-only property to access the total number of regular files in all child hierarchy levels (recursively).
459 :returns: Total number of regular files.
460 """
461 return sum(d.TotalRegularFileCount for d in self._subdirectories.values()) + len(self._files)
463 @readonly
464 def TotalSymbolicLinkCount(self) -> int:
465 """
466 Read-only property to access the total number of symbolic links in all child hierarchy levels (recursively).
468 :returns: Total number of symbolic links.
469 """
470 return sum(d.TotalSymbolicLinkCount for d in self._subdirectories.values()) + len(self._symbolicLinks)
472 @readonly
473 def TotalSubdirectoryCount(self) -> int:
474 """
475 Read-only property to access the total number of subdirectories in all child hierarchy levels (recursively).
477 :returns: Total number of subdirectories.
478 """
479 return len(self._subdirectories) + sum(d.TotalSubdirectoryCount for d in self._subdirectories.values())
481 @readonly
482 def Subdirectories(self) -> Generator["Directory", None, None]:
483 """
484 Iterate all direct subdirectories of the directory.
486 :returns: A generator to iterate all direct subdirectories.
487 """
488 return (d for d in self._subdirectories.values())
490 @readonly
491 def Files(self) -> Generator[Union["Filename", "SymbolicLink"], None, None]:
492 """
493 Iterate all direct files of the directory.
495 .. hint::
497 Files include regular files and symbolic links.
499 :returns: A generator to iterate all direct files.
500 """
501 return (f for f in chain(self._files.values(), self._symbolicLinks.values()))
503 @readonly
504 def RegularFiles(self) -> Generator["Filename", None, None]:
505 """
506 Iterate all direct regular files of the directory.
508 :returns: A generator to iterate all direct regular files.
509 """
510 return (f for f in self._files.values())
512 @readonly
513 def SymbolicLinks(self) -> Generator["SymbolicLink", None, None]:
514 """
515 Iterate all direct symbolic links of the directory.
517 :returns: A generator to iterate all direct symbolic links.
518 """
519 return (l for l in self._symbolicLinks.values())
521 @readonly
522 def Path(self) -> Path:
523 """
524 Read-only property to access the equivalent Path instance for accessing the represented directory.
526 :returns: Path to the directory.
527 :raises FilesystemException: If no parent is set.
528 """
529 if self._path is not None:
530 return self._path
532 if self._parent is None:
533 raise FilesystemException(f"No parent or root set for directory.")
535 self._path = self._parent.Path / self._name
536 return self._path
538 @readonly
539 def ScanDuration(self) -> float:
540 """
541 Read-only property to access the time needed to scan a directory structure including all subelements (recursively).
543 :returns: The scan duration in seconds.
544 :raises FilesystemException: If the directory was not scanned.
545 """
546 if self._scanDuration is None:
547 raise FilesystemException(f"Directory was not scanned, yet.")
549 return self._scanDuration
551 @readonly
552 def AggregateDuration(self) -> float:
553 """
554 Read-only property to access the time needed to aggregate the directory's and subelement's properties (recursively).
556 :returns: The aggregation duration in seconds.
557 :raises FilesystemException: If the directory properties were not aggregated.
558 """
559 if self._scanDuration is None:
560 raise FilesystemException(f"Directory properties were not aggregated, yet.")
562 return self._aggregateDuration
564 def Copy(self, parent: Nullable["Directory"] = None) -> "Directory":
565 """
566 Copy the directory structure including all subelements and link it to the given parent.
568 .. hint::
570 Statistics like aggregated directory size are copied too. |br|
571 There is no rescan or repeated aggregation needed.
573 :param parent: The parent element of the copied directory.
574 :returns: A deep copy of the directory structure.
575 """
576 dir = Directory(self._name, parent=parent)
577 dir._size = self._size
579 for subdir in self._subdirectories.values():
580 subdir.Copy(dir)
582 for file in self._files.values():
583 file.Copy(dir)
585 for link in self._symbolicLinks.values():
586 link.Copy(dir)
588 return dir
590 def Collapse(self, func: Callable[["Directory"], bool]) -> bool:
591 # if len(self._subdirectories) == 0 or all(subdir.Collapse(func) for subdir in self._subdirectories.values()):
592 if len(self._subdirectories) == 0:
593 if func(self):
594 # print(f"collapse 1 {self.Path}")
595 self._collapsed = True
596 self._subdirectories.clear()
597 self._files.clear()
598 self._symbolicLinks.clear()
600 return True
601 else:
602 return False
604 # if all(subdir.Collapse(func) for subdir in self._subdirectories.values())
605 collapsible = True
606 for subdir in self._subdirectories.values():
607 result = subdir.Collapse(func)
608 collapsible = collapsible and result
610 if collapsible:
611 # print(f"collapse 2 {self.Path}")
612 self._collapsed = True
613 self._subdirectories.clear()
614 self._files.clear()
615 self._symbolicLinks.clear()
617 return True
618 else:
619 return False
621 def ToTree(self, format: Nullable[Callable[[Node], str]] = None) -> Node:
622 """
623 Convert the directory to a :class:`~pyTooling.Tree.Node`.
625 The node's :attr:`~pyTooling.Tree.Node.Value` field contains a reference to the directory. Additional data is
626 attached to the node's key-value store:
628 ``kind``
629 The node's kind. See :class:`NodeKind`.
630 ``size``
631 The directory's aggregated size.
633 :param format: A user defined formatting function for tree nodes.
634 :returns: A tree node representing this directory.
635 """
636 if format is None:
637 def format(node: Node) -> str:
638 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
640 directoryNode = Node(
641 value=self,
642 keyValuePairs={
643 "kind": NodeKind.File,
644 "size": self._size
645 },
646 format=format
647 )
648 directoryNode.AddChildren(
649 e.ToTree(format) for e in chain(self._subdirectories.values()) #, self._files.values(), self._symbolicLinks.values())
650 )
652 return directoryNode
654 def __eq__(self, other) -> bool:
655 """
656 Compare two Directory instances for equality.
658 :param other: Parameter to compare against.
659 :returns: ``True``, if both directories and all its subelements are equal.
660 :raises TypeError: If parameter ``other`` is not of type :class:`Directory`.
661 """
662 if not isinstance(other, Directory):
663 ex = TypeError("Parameter 'other' is not of type Directory.")
664 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
665 raise ex
667 if not all(dir1 == dir2 for _, dir1, dir2 in zipdicts(self._subdirectories, other._subdirectories)):
668 return False
670 if not all(file1 == file2 for _, file1, file2 in zipdicts(self._files, other._files)):
671 return False
673 if not all(link1 == link2 for _, link1, link2 in zipdicts(self._symbolicLinks, other._symbolicLinks)):
674 return False
676 return True
678 def __ne__(self, other: Any) -> bool:
679 """
680 Compare two Directory instances for inequality.
682 :param other: Parameter to compare against.
683 :returns: ``True``, if both directories and all its subelements are unequal.
684 :raises TypeError: If parameter ``other`` is not of type :class:`Directory`.
685 """
686 return not self.__eq__(other)
688 def __repr__(self) -> str:
689 return f"Directory: {self.Path}"
691 def __str__(self) -> str:
692 return self._name
695@export
696class Filename(Element[Directory]):
697 """
698 Represents a filename in the filesystem, but not the file storage object (:class:`File`).
700 .. hint::
702 Filename and file storage are represented by two classes, which allows multiple names (hard links) per file storage
703 object.
704 """
705 _file: Nullable["File"]
707 def __init__(
708 self,
709 name: str,
710 file: Nullable["File"] = None,
711 parent: Nullable[Directory] = None
712 ) -> None:
713 """
714 Initialize the filename with name, file (storage) object and parent reference.
716 :param name: Name of the file.
717 :param size: Optional file (storage) object.
718 :param parent: Optional parent reference.
719 """
720 super().__init__(name, None, parent)
722 if file is None: 722 ↛ 725line 722 didn't jump to line 725 because the condition on line 722 was always true
723 self._file = None
724 else:
725 self._file = file
726 file._parents.append(self)
728 if parent is not None:
729 parent._files[name] = self
731 if parent._root is not None:
732 self._root = parent._root
734 @Element.Root.setter
735 def Root(self, value: "Root") -> None:
736 self._root = value
738 if self._file is not None: 738 ↛ exitline 738 didn't return from function 'Root' because the condition on line 738 was always true
739 self._file._root = value
741 @Element.Parent.setter
742 def Parent(self, value: _ParentType) -> None:
743 Element.Parent.fset(self, value)
745 value._files[self._name] = self
747 if isinstance(value, Root): 747 ↛ 748line 747 didn't jump to line 748 because the condition on line 747 was never true
748 self.Root = value
750 @readonly
751 def File(self) -> Nullable["File"]:
752 return self._file
754 @readonly
755 def Size(self) -> int:
756 if self._file is None:
757 raise ToolingException(f"Filename isn't linked to a File object.")
759 return self._file._size
761 @readonly
762 def Path(self) -> Path:
763 if self._parent is None:
764 raise ToolingException(f"Filename has no parent object.")
766 return self._parent.Path / self._name
768 def Copy(self, parent: Directory) -> "Filename":
769 fileID = self._file._id
771 if fileID in parent._root._ids:
772 file = parent._root._ids[fileID]
773 else:
774 fileSize = self._file._size
775 file = File(fileID, fileSize)
777 parent._root._ids[fileID] = file
779 return Filename(self._name, file, parent=parent)
781 def ToTree(self) -> Node:
782 def format(node: Node) -> str:
783 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
785 fileNode = Node(
786 value=self,
787 keyValuePairs={
788 "kind": NodeKind.File,
789 "size": self._size
790 },
791 format=format
792 )
794 return fileNode
796 def __eq__(self, other) -> bool:
797 """
798 Compare two Filename instances for equality.
800 :param other: Parameter to compare against.
801 :returns: ``True``, if both filenames are equal.
802 :raises TypeError: If parameter ``other`` is not of type :class:`Filename`.
803 """
804 if not isinstance(other, Filename):
805 ex = TypeError("Parameter 'other' is not of type Filename.")
806 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
807 raise ex
809 return self._name == other._name and self.Size == other.Size
811 def __ne__(self, other: Any) -> bool:
812 """
813 Compare two Filename instances for inequality.
815 :param other: Parameter to compare against.
816 :returns: ``True``, if both filenames are unequal.
817 :raises TypeError: If parameter ``other`` is not of type :class:`Filename`.
818 """
819 if not isinstance(other, Filename):
820 ex = TypeError("Parameter 'other' is not of type Filename.")
821 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
822 raise ex
824 return self._name != other._name or self.Size != other.Size
826 def __repr__(self) -> str:
827 return f"File: {self.Path}"
829 def __str__(self) -> str:
830 return self._name
833@export
834class SymbolicLink(Element[Directory]):
835 _target: Path
837 def __init__(
838 self,
839 name: str,
840 target: Path,
841 parent: Nullable[Directory]
842 ) -> None:
843 super().__init__(name, None, parent)
845 self._target = target
847 if parent is not None:
848 parent._symbolicLinks[name] = self
850 if parent._root is not None:
851 self._root = parent._root
853 @readonly
854 def Path(self) -> Path:
855 return self._parent.Path / self._name
857 @readonly
858 def Target(self) -> Path:
859 return self._target
861 def Copy(self, parent: Directory) -> "SymbolicLink":
862 return SymbolicLink(self._name, self._target, parent=parent)
864 def ToTree(self) -> Node:
865 def format(node: Node) -> str:
866 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
868 symbolicLinkNode = Node(
869 value=self,
870 keyValuePairs={
871 "kind": NodeKind.SymbolicLink,
872 "size": self._size
873 },
874 format=format
875 )
877 return symbolicLinkNode
879 def __eq__(self, other) -> bool:
880 """
881 Compare two SymbolicLink instances for equality.
883 :param other: Parameter to compare against.
884 :returns: ``True``, if both symbolic links are equal.
885 :raises TypeError: If parameter ``other`` is not of type :class:`SymbolicLink`.
886 """
887 if not isinstance(other, SymbolicLink):
888 ex = TypeError("Parameter 'other' is not of type SymbolicLink.")
889 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
890 raise ex
892 return self._name == other._name and self._target == other._target
894 def __ne__(self, other: Any) -> bool:
895 """
896 Compare two SymbolicLink instances for inequality.
898 :param other: Parameter to compare against.
899 :returns: ``True``, if both symbolic links are unequal.
900 :raises TypeError: If parameter ``other`` is not of type :class:`SymbolicLink`.
901 """
902 if not isinstance(other, SymbolicLink):
903 ex = TypeError("Parameter 'other' is not of type SymbolicLink.")
904 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
905 raise ex
907 return self._name != other._name or self._target != other._target
909 def __repr__(self) -> str:
910 return f"SymLink: {self.Path} -> {self._target}"
912 def __str__(self) -> str:
913 return self._name
916@export
917class Root(Directory):
918 """
919 A **Root** represents the root-directory in the filesystem, which contains subdirectories, regular files and symbolic links.
920 """
921 _ids: Dict[int, "File"] #: Dictionary of file identifier - file objects pairs found while scanning the directory structure.
923 def __init__(
924 self,
925 rootDirectory: Path,
926 collectSubdirectories: bool = True
927 ) -> None:
928 if rootDirectory is None: 928 ↛ 929line 928 didn't jump to line 929 because the condition on line 928 was never true
929 raise ValueError(f"Parameter 'rootDirectory' is None.")
930 elif not isinstance(rootDirectory, Path): 930 ↛ 931line 930 didn't jump to line 931 because the condition on line 930 was never true
931 raise TypeError(f"Parameter 'rootDirectory' is not of type 'Path'.")
932 elif not rootDirectory.exists(): 932 ↛ 933line 932 didn't jump to line 933 because the condition on line 932 was never true
933 raise ToolingException(f"Path '{rootDirectory}' doesn't exist.") from FileNotFoundError(rootDirectory)
935 self._ids = {}
937 super().__init__(rootDirectory.name)
938 self._root = self
939 self._path = rootDirectory
941 if collectSubdirectories: 941 ↛ 942line 941 didn't jump to line 942 because the condition on line 941 was never true
942 self._collectSubdirectories()
943 self._connectSymbolicLinks()
945 @readonly
946 def TotalHardLinkCount(self) -> int:
947 return sum(l for f in self._ids.values() if (l := len(f._parents)) > 1)
949 @readonly
950 def TotalHardLinkCount2(self) -> int:
951 return sum(1 for f in self._ids.values() if len(f._parents) > 1)
953 @readonly
954 def TotalHardLinkCount3(self) -> int:
955 return sum(1 for f in self._ids.values() if len(f._parents) == 1)
957 @readonly
958 def Size2(self) -> int:
959 return sum(f._size for f in self._ids.values() if len(f._parents) > 1)
961 @readonly
962 def Size3(self) -> int:
963 return sum(f._size * len(f._parents) for f in self._ids.values() if len(f._parents) > 1)
965 @readonly
966 def TotalUniqueFileCount(self) -> int:
967 return len(self._ids)
969 @readonly
970 def Path(self) -> Path:
971 """
972 Read-only property to access the path of the filesystem statistics root.
974 :returns: Path to the root of the filesystem statistics root directory.
975 """
976 return self._path
978 def Copy(self) -> "Root":
979 """
980 Copy the directory structure including all subelements and link it to the given parent.
982 The duration for the deep copy process is provided in :attr:`ScanDuration`
984 .. hint::
986 Statistics like aggregated directory size are copied too. |br|
987 There is no rescan or repeated aggregation needed.
989 :returns: A deep copy of the directory structure.
990 """
991 with Stopwatch() as sw:
992 root = Root(self._path, False)
993 root._size = self._size
995 for subdir in self._subdirectories.values():
996 subdir.Copy(root)
998 for file in self._files.values():
999 file.Copy(root)
1001 for link in self._symbolicLinks.values():
1002 link.Copy(root)
1004 root._scanDuration = sw.Duration
1005 root._aggregateDuration = 0.0
1007 return root
1009 def __repr__(self) -> str:
1010 return f"Root: {self.Path} (dirs: {self.TotalSubdirectoryCount}, files: {self.TotalRegularFileCount}, symlinks: {self.TotalSymbolicLinkCount})"
1012 def __str__(self) -> str:
1013 return self._name
1016@export
1017class File(Base):
1018 """
1019 A **File** represents a file storage object in the filesystem, which is accessible by one or more :class:`Filename` objects.
1021 Each file has an internal id, which is associated to a unique ID within the host's filesystem.
1022 """
1023 _id: int #: Unique (host internal) file object ID)
1024 _parents: List[Filename] #: List of reverse references to :class:`Filename` objects.
1026 def __init__(
1027 self,
1028 id: int,
1029 size: int,
1030 parent: Nullable[Filename] = None
1031 ) -> None:
1032 """
1033 Initialize the File storage object with an ID, size and parent reference.
1035 :param id: Unique ID of the file object.
1036 :param size: Size of the file object.
1037 :param parent: Optional parent reference.
1038 """
1039 if not isinstance(id, int): 1039 ↛ 1040line 1039 didn't jump to line 1040 because the condition on line 1039 was never true
1040 ex = TypeError("Parameter 'id' is not of type 'int'.")
1041 ex.add_note(f"Got type '{getFullyQualifiedName(id)}'.")
1042 raise ex
1044 self._id = id
1046 if parent is None:
1047 super().__init__(size, None)
1048 self._parents = []
1049 elif isinstance(parent, Filename): 1049 ↛ 1054line 1049 didn't jump to line 1054 because the condition on line 1049 was always true
1050 super().__init__(size, parent._root)
1051 self._parents = [parent]
1052 parent._file = self
1053 else:
1054 ex = TypeError("Parameter 'parent' is not of type 'Filename'.")
1055 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
1056 raise ex
1058 @readonly
1059 def ID(self) -> int:
1060 """
1061 Read-only property to access the file object's unique identifier.
1063 :returns: Unique file object identifier.
1064 """
1065 return self._id
1067 @readonly
1068 def Parents(self) -> List[Filename]:
1069 """
1070 Read-only property to access the list of filenames using the same file storage object.
1072 .. hint::
1074 This allows to check if a file object has multiple filenames a.k.a hardlinks.
1076 :returns: List of filenames for the file storage object.
1077 """
1078 return self._parents
1080 def AddParent(self, file: Filename) -> None:
1081 """
1082 Add another parent reference to a :class:`Filename`.
1084 :param file: Reference to a filename object.
1085 """
1086 if not isinstance(file, Filename): 1086 ↛ 1087line 1086 didn't jump to line 1087 because the condition on line 1086 was never true
1087 ex = TypeError("Parameter 'file' is not of type 'Filename'.")
1088 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.")
1089 raise ex
1090 elif file._file is not None: 1090 ↛ 1091line 1090 didn't jump to line 1091 because the condition on line 1090 was never true
1091 raise ToolingException(f"Filename is already referencing an other file object ({file._file._id}).")
1093 self._parents.append(file)
1094 file._file = self
1096 if file._root is not None:
1097 self._root = file._root