Coverage for pyTooling/Filesystem/__init__.py: 49%
458 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-25 22:22 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-25 22:22 +0000
1# ==================================================================================================================== #
2# _____ _ _ _____ _ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | ___(_) | ___ ___ _ _ ___| |_ ___ _ __ ___ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |_ | | |/ _ \/ __| | | / __| __/ _ \ '_ ` _ \ #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| _| | | | __/\__ \ |_| \__ \ || __/ | | | | | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_| |_|_|\___||___/\__, |___/\__\___|_| |_| |_| #
7# |_| |___/ |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2025-2025 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
39from sys import version_info
41from enum import Enum
42from itertools import chain
43from pathlib import Path
44from typing import Optional as Nullable, Dict, Generic, Generator, TypeVar, List, Any, Callable, Union
46try:
47 from pyTooling.Decorators import readonly, export
48 from pyTooling.Exceptions import ToolingException
49 from pyTooling.MetaClasses import ExtendedType, abstractmethod
50 from pyTooling.Common import getFullyQualifiedName, zipdicts
51 from pyTooling.Stopwatch import Stopwatch
52 from pyTooling.Tree import Node
53except (ImportError, ModuleNotFoundError): # pragma: no cover
54 print("[pyTooling.Filesystem] Could not import from 'pyTooling.*'!")
56 try:
57 from pyTooling.Decorators import readonly, export
58 from pyTooling.Exceptions import ToolingException
59 from pyTooling.MetaClasses import ExtendedType, abstractmethod
60 from pyTooling.Common import getFullyQualifiedName
61 from pyTooling.Stopwatch import Stopwatch
62 from pyTooling.Tree import Node
63 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
64 print("[pyTooling.Filesystem] Could not import directly!")
65 raise ex
68_ParentType = TypeVar("_ParentType", bound="Element")
71@export
72class FilesystemException(ToolingException):
73 """Base-exception of all exceptions raised by :mod:`pyTooling.Filesystem`."""
76@export
77class NodeKind(Enum):
78 """
79 Node kind for filesystem elements in a :ref:`tree <STRUCT/Tree>`.
81 This enumeration is used when converting the filesystem statistics tree to an instance of :mod:`pyTooling.Tree`.
82 """
83 Directory = 0 #: Node represents a directory.
84 File = 1 #: Node represents a regular file.
85 SymbolicLink = 2 #: Node represents a symbolic link.
88@export
89class Base(metaclass=ExtendedType, slots=True):
90 _root: Nullable["Root"] #: Reference to the root of the filesystem statistics scope.
91 _size: Nullable[int] #: Actual or aggregated size of the filesystem element.
93 def __init__(
94 self,
95 root: Nullable["Root"],
96 size: Nullable[int],
97 ) -> None:
98 self._root = root
99 self._size = size
101 @property
102 def Root(self) -> Nullable["Root"]:
103 """
104 Property to access the root of the filesystem statistics scope.
106 :returns: Root of the filesystem statistics scope.
107 """
108 return self._root
110 @Root.setter
111 def Root(self, value: "Root") -> None:
112 self._root = value
114 @readonly
115 def Size(self) -> int:
116 """
117 Read-only property to access the elements size in Bytes.
119 :returns: Size in Bytes.
120 :raises FilesystemException: If size is not computed, yet.
121 """
122 if self._size is None: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true
123 raise FilesystemException("Size is not computed, yet.")
125 return self._size
127 # @abstractmethod
128 def ToTree(self) -> Node:
129 pass
132@export
133class Element(Base, Generic[_ParentType]):
134 _name: str #: Name of the filesystem element.
135 _parent: _ParentType #: Reference to the filesystem element's parent (:class:`Directory`)
136 _linkSources: List["SymbolicLink"] #: A list of symbolic links pointing to this filesystem element.
138 def __init__(
139 self,
140 name: str,
141 size: Nullable[int] = None,
142 parent: Nullable[_ParentType] = None
143 ) -> None:
144 root = None # if parent is None else parent._root
146 super().__init__(root, size)
148 self._parent = parent
149 self._name = name
150 self._linkSources = []
152 @property
153 def Parent(self) -> _ParentType:
154 return self._parent
156 @Parent.setter
157 def Parent(self, value: _ParentType) -> None:
158 self._parent = value
160 if value._root is not None:
161 self._root = value._root
163 @readonly
164 def Name(self) -> str:
165 """
166 Read-only property to access the elements name.
168 :returns: Element name.
169 """
170 return self._name
172 @readonly
173 def Path(self) -> Path:
174 raise NotImplemented(f"Property 'Path' is abstract.")
176 def AddLinkSources(self, source: "SymbolicLink") -> None:
177 self._linkSources.append(source)
180@export
181class Directory(Element["Directory"]):
182 """
183 A **directory** represents a directory in the filesystem contains subdirectories, regular files and symbolic links.
185 While scanning for subelements, the directory is populated with elements. Every file object added, gets registered in
186 the filesystems :class:`Root` for deduplication. In case a file identifier already exists, the found filename will
187 reference the same file objects. In turn, the file objects has then references to multiple filenames (parents). This
188 allows to detect :def:`hardlinks <hardlink>`.
190 The time needed for scanning the directory and its subelements is provided via :attr:`ScanDuration`.
192 After scnaning the directory for subelements, certain directory properties get aggregated. The time needed for
193 aggregation is provided via :attr:`AggregateDuration`.
194 """
196 _path: Nullable[Path] #: Cached :class:`~pathlib.Path` object of this directory.
197 _subdirectories: Dict[str, "Directory"] #: Dictionary containing name-:class:`Directory` pairs.
198 _files: Dict[str, "Filename"] #: Dictionary containing name-:class:`Filename` pairs.
199 _symbolicLinks: Dict[str, "SymbolicLink"] #: Dictionary containing name-:class:`SymbolicLink` pairs.
200 _collapsed: bool #: True, if this directory was collapsed. It contains no subelements.
201 _scanDuration: Nullable[float] #: Duration for scanning the directory and all its subelements.
202 _aggregateDuration: Nullable[float] #: Duration for aggregating all subelements.
204 def __init__(
205 self,
206 name: str,
207 collectSubdirectories: bool = False,
208 parent: Nullable["Directory"] = None
209 ) -> None:
210 super().__init__(name, None, parent)
212 self._path = None
213 self._subdirectories = {}
214 self._files = {}
215 self._symbolicLinks = {}
216 self._collapsed = False
217 self._scanDuration = None
218 self._aggregateDuration = None
220 if parent is not None:
221 parent._subdirectories[name] = self
223 if parent._root is not None: 223 ↛ 226line 223 didn't jump to line 226 because the condition on line 223 was always true
224 self._root = parent._root
226 if collectSubdirectories: 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true
227 self._collectSubdirectories()
229 def _collectSubdirectories(self) -> None:
230 with Stopwatch() as sw1:
231 self._scanSubdirectories()
233 with Stopwatch() as sw2:
234 self._aggregateSizes()
236 self._scanDuration = sw1.Duration
237 self._aggregateDuration = sw2.Duration
239 def _scanSubdirectories(self) -> None:
240 try:
241 items = scandir(directoryPath := self.Path)
242 except PermissionError as ex:
243 return
245 for dirEntry in items:
246 if dirEntry.is_dir(follow_symlinks=False):
247 subdirectory = Directory(dirEntry.name, collectSubdirectories=True, parent=self)
248 elif dirEntry.is_file(follow_symlinks=False):
249 id = dirEntry.inode()
250 if id in self._root._ids:
251 file = self._root._ids[id]
253 hardLink = Filename(dirEntry.name, file=file, parent=self)
254 else:
255 s = dirEntry.stat(follow_symlinks=False)
256 filename = Filename(dirEntry.name, parent=self)
257 file = File(id, s.st_size, parent=filename)
259 self._root._ids[id] = file
260 elif dirEntry.is_symlink():
261 target = Path(readlink(directoryPath / dirEntry.name))
262 symlink = SymbolicLink(dirEntry.name, target, parent=self)
263 else:
264 raise FilesystemException(f"Unknown directory element.")
266 def _connectSymbolicLinks(self) -> None:
267 for dir in self._subdirectories.values():
268 dir._connectSymbolicLinks()
270 for link in self._symbolicLinks.values():
271 if link._target.is_absolute():
272 pass
273 else:
274 target = self
275 for elem in link._target.parts:
276 if elem == ".":
277 continue
278 elif elem == "..":
279 target = target._parent
280 continue
282 try:
283 target = target._subdirectories[elem]
284 continue
285 except KeyError:
286 pass
288 try:
289 target = target._files[elem]
290 continue
291 except KeyError:
292 pass
294 try:
295 target = target._symbolicLinks[elem]
296 continue
297 except KeyError:
298 pass
300 target.AddLinkSources(link)
302 def _aggregateSizes(self) -> None:
303 self._size = (
304 sum(dir._size for dir in self._subdirectories.values()) +
305 sum(file._file._size for file in self._files.values())
306 )
308 @Element.Root.setter
309 def Root(self, value: "Root") -> None:
310 Element.Root.fset(self, value)
312 for subdir in self._subdirectories.values(): 312 ↛ 313line 312 didn't jump to line 313 because the loop on line 312 never started
313 subdir.Root = value
315 for file in self._files.values():
316 file.Root = value
318 for link in self._symbolicLinks.values(): 318 ↛ 319line 318 didn't jump to line 319 because the loop on line 318 never started
319 link.Root = value
321 @Element.Parent.setter
322 def Parent(self, value: _ParentType) -> None:
323 Element.Parent.fset(self, value)
325 value._subdirectories[self._name] = self
327 if isinstance(value, Root): 327 ↛ exitline 327 didn't return from function 'Parent' because the condition on line 327 was always true
328 self.Root = value
330 @readonly
331 def Count(self) -> int:
332 """
333 Read-only property to access the number of elements in a directory.
335 :returns: Number of files plus subdirectories.
336 """
337 return len(self._subdirectories) + len(self._files) + len(self._symbolicLinks)
339 @readonly
340 def FileCount(self) -> int:
341 """
342 Read-only property to access the number of files in a directory.
344 .. hint::
346 Files include regular files and symbolic links.
348 :returns: Number of files.
349 """
350 return len(self._files) + len(self._symbolicLinks)
352 @readonly
353 def RegularFileCount(self) -> int:
354 """
355 Read-only property to access the number of regular files in a directory.
357 :returns: Number of regular files.
358 """
359 return len(self._files)
361 @readonly
362 def SymbolicLinkCount(self) -> int:
363 """
364 Read-only property to access the number of symbolic links in a directory.
366 :returns: Number of symbolic links.
367 """
368 return len(self._symbolicLinks)
370 @readonly
371 def SubdirectoryCount(self) -> int:
372 """
373 Read-only property to access the number of subdirectories in a directory.
375 :returns: Number of subdirectories.
376 """
377 return len(self._subdirectories)
379 @readonly
380 def TotalFileCount(self) -> int:
381 """
382 Read-only property to access the total number of files in all child hierarchy levels (recursively).
384 .. hint::
386 Files include regular files and symbolic links.
388 :returns: Total number of files.
389 """
390 return sum(d.TotalFileCount for d in self._subdirectories.values()) + len(self._files) + len(self._symbolicLinks)
392 @readonly
393 def TotalRegularFileCount(self) -> int:
394 """
395 Read-only property to access the total number of regular files in all child hierarchy levels (recursively).
397 :returns: Total number of regular files.
398 """
399 return sum(d.TotalRegularFileCount for d in self._subdirectories.values()) + len(self._files)
401 @readonly
402 def TotalSymbolicLinkCount(self) -> int:
403 """
404 Read-only property to access the total number of symbolic links in all child hierarchy levels (recursively).
406 :returns: Total number of symbolic links.
407 """
408 return sum(d.TotalSymbolicLinkCount for d in self._subdirectories.values()) + len(self._symbolicLinks)
410 @readonly
411 def TotalSubdirectoryCount(self) -> int:
412 """
413 Read-only property to access the total number of subdirectories in all child hierarchy levels (recursively).
415 :returns: Total number of subdirectories.
416 """
417 return len(self._subdirectories) + sum(d.TotalSubdirectoryCount for d in self._subdirectories.values())
419 @readonly
420 def Subdirectories(self) -> Generator["Directory", None, None]:
421 """
422 Iterate all direct subdirectories of the directory.
424 :returns: A generator to iterate all direct subdirectories.
425 """
426 return (d for d in self._subdirectories.values())
428 @readonly
429 def Files(self) -> Generator[Union["Filename", "SymbolicLink"], None, None]:
430 """
431 Iterate all direct files of the directory.
433 .. hint::
435 Files include regular files and symbolic links.
437 :returns: A generator to iterate all direct files.
438 """
439 return (f for f in chain(self._files.values(), self._symbolicLinks.values()))
441 @readonly
442 def RegularFiles(self) -> Generator["Filename", None, None]:
443 """
444 Iterate all direct regular files of the directory.
446 :returns: A generator to iterate all direct regular files.
447 """
448 return (f for f in self._files.values())
450 @readonly
451 def SymbolicLinks(self) -> Generator["SymbolicLink", None, None]:
452 """
453 Iterate all direct symbolic links of the directory.
455 :returns: A generator to iterate all direct symbolic links.
456 """
457 return (l for l in self._symbolicLinks.values())
459 @readonly
460 def Path(self) -> Path:
461 """
462 Read-only property to access the equivalent Path instance for accessing the represented directory.
464 :returns: Path to the directory.
465 :raises FilesystemException: If no parent is set.
466 """
467 if self._path is not None:
468 return self._path
470 if self._parent is None:
471 raise FilesystemException(f"No parent or root set for directory.")
473 self._path = self._parent.Path / self._name
474 return self._path
476 @readonly
477 def ScanDuration(self) -> float:
478 """
479 Read-only property to access the time needed to scan a directory structure including all subelements (recursively).
481 :returns: The scan duration in seconds.
482 :raises FilesystemException: If the directory was not scanned.
483 """
484 if self._scanDuration is None:
485 raise FilesystemException(f"Directory was not scanned, yet.")
487 return self._scanDuration
489 @readonly
490 def AggregateDuration(self) -> float:
491 """
492 Read-only property to access the time needed to aggregate the directory's and subelement's properties (recursively).
494 :returns: The aggregation duration in seconds.
495 :raises FilesystemException: If the directory properties were not aggregated.
496 """
497 if self._scanDuration is None:
498 raise FilesystemException(f"Directory properties were not aggregated, yet.")
500 return self._aggregateDuration
502 def Copy(self, parent: Nullable["Directory"] = None) -> "Directory":
503 """
504 Copy the directory structure including all subelements and link it to the given parent.
506 .. hint::
508 Statistics like aggregated directory size are copied too. |br|
509 There is no rescan or repeated aggregation needed.
511 :param parent: The parent element of the copied directory.
512 :returns: A deep copy of the directory structure.
513 """
514 dir = Directory(self._name, parent=parent)
515 dir._size = self._size
517 for subdir in self._subdirectories.values():
518 subdir.Copy(dir)
520 for file in self._files.values():
521 file.Copy(dir)
523 for link in self._symbolicLinks.values():
524 link.Copy(dir)
526 return dir
528 def Collapse(self, func: Callable[["Directory"], bool]) -> bool:
529 # if len(self._subdirectories) == 0 or all(subdir.Collapse(func) for subdir in self._subdirectories.values()):
530 if len(self._subdirectories) == 0:
531 if func(self):
532 # print(f"collapse 1 {self.Path}")
533 self._collapsed = True
534 self._subdirectories.clear()
535 self._files.clear()
536 self._symbolicLinks.clear()
538 return True
539 else:
540 return False
542 # if all(subdir.Collapse(func) for subdir in self._subdirectories.values())
543 collapsible = True
544 for subdir in self._subdirectories.values():
545 result = subdir.Collapse(func)
546 collapsible = collapsible and result
548 if collapsible:
549 # print(f"collapse 2 {self.Path}")
550 self._collapsed = True
551 self._subdirectories.clear()
552 self._files.clear()
553 self._symbolicLinks.clear()
555 return True
556 else:
557 return False
559 def ToTree(self, format: Nullable[Callable[[Node], str]] = None) -> Node:
560 """
561 Convert the directory to a :class:`~pyTooling.Tree.Node`.
563 The node's :attr:`~pyTooling.Tree.Node.Value` field contains a reference to the directory. Additional data is
564 attached to the node's key-value store:
566 ``kind``
567 The node's kind. See :class:`NodeKind`.
568 ``size``
569 The directory's aggregated size.
571 :param format: A user defined formatting function for tree nodes.
572 :returns: A tree node representing this directory.
573 """
574 if format is None:
575 def format(node: Node) -> str:
576 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
578 directoryNode = Node(
579 value=self,
580 keyValuePairs={
581 "kind": NodeKind.File,
582 "size": self._size
583 },
584 format=format
585 )
586 directoryNode.AddChildren(
587 e.ToTree(format) for e in chain(self._subdirectories.values()) #, self._files.values(), self._symbolicLinks.values())
588 )
590 return directoryNode
592 def __eq__(self, other) -> bool:
593 """
594 Compare two Directory instances for equality.
596 :param other: Parameter to compare against.
597 :returns: ``True``, if both directories and all its subelements are equal.
598 :raises TypeError: If parameter ``other`` is not of type :class:`Directory`.
599 """
600 if not isinstance(other, Directory):
601 ex = TypeError("Parameter 'other' is not of type Directory.")
602 if version_info >= (3, 11): # pragma: no cover
603 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
604 raise ex
606 if not all(dir1 == dir2 for _, dir1, dir2 in zipdicts(self._subdirectories, other._subdirectories)):
607 return False
609 if not all(file1 == file2 for _, file1, file2 in zipdicts(self._files, other._files)):
610 return False
612 if not all(link1 == link2 for _, link1, link2 in zipdicts(self._symbolicLinks, other._symbolicLinks)):
613 return False
615 return True
617 def __ne__(self, other: Any) -> bool:
618 """
619 Compare two Directory instances for inequality.
621 :param other: Parameter to compare against.
622 :returns: ``True``, if both directories and all its subelements are unequal.
623 :raises TypeError: If parameter ``other`` is not of type :class:`Directory`.
624 """
625 return not self.__eq__(other)
627 def __repr__(self) -> str:
628 return f"Directory: {self.Path}"
630 def __str__(self) -> str:
631 return self._name
634@export
635class Filename(Element[Directory]):
636 _file: Nullable["File"]
638 def __init__(
639 self,
640 name: str,
641 file: Nullable["File"] = None,
642 parent: Nullable[Directory] = None
643 ) -> None:
644 super().__init__(name, None, parent)
646 if file is None: 646 ↛ 649line 646 didn't jump to line 649 because the condition on line 646 was always true
647 self._file = None
648 else:
649 self._file = file
650 file._parents.append(self)
652 if parent is not None:
653 parent._files[name] = self
655 if parent._root is not None:
656 self._root = parent._root
658 @Element.Root.setter
659 def Root(self, value: "Root") -> None:
660 self._root = value
662 if self._file is not None: 662 ↛ exitline 662 didn't return from function 'Root' because the condition on line 662 was always true
663 self._file._root = value
665 @Element.Parent.setter
666 def Parent(self, value: _ParentType) -> None:
667 Element.Parent.fset(self, value)
669 value._files[self._name] = self
671 if isinstance(value, Root): 671 ↛ 672line 671 didn't jump to line 672 because the condition on line 671 was never true
672 self.Root = value
674 @readonly
675 def File(self) -> Nullable["File"]:
676 return self._file
678 @readonly
679 def Size(self) -> int:
680 if self._file is None:
681 raise ToolingException(f"Filename isn't linked to a File object.")
683 return self._file._size
685 @readonly
686 def Path(self) -> Path:
687 if self._parent is None:
688 raise ToolingException(f"Filename has no parent object.")
690 return self._parent.Path / self._name
692 def Copy(self, parent: Directory) -> "Filename":
693 fileID = self._file._id
695 if fileID in parent._root._ids:
696 file = parent._root._ids[fileID]
697 else:
698 fileSize = self._file._size
699 file = File(fileID, fileSize)
701 parent._root._ids[fileID] = file
703 return Filename(self._name, file, parent=parent)
705 def ToTree(self) -> Node:
706 def format(node: Node) -> str:
707 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
709 fileNode = Node(
710 value=self,
711 keyValuePairs={
712 "kind": NodeKind.File,
713 "size": self._size
714 },
715 format=format
716 )
718 return fileNode
720 def __eq__(self, other) -> bool:
721 """
722 Compare two Filename instances for equality.
724 :param other: Parameter to compare against.
725 :returns: ``True``, if both filenames are equal.
726 :raises TypeError: If parameter ``other`` is not of type :class:`Filename`.
727 """
728 if not isinstance(other, Filename):
729 ex = TypeError("Parameter 'other' is not of type Filename.")
730 if version_info >= (3, 11): # pragma: no cover
731 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
732 raise ex
734 return self._name == other._name and self.Size == other.Size
736 def __ne__(self, other: Any) -> bool:
737 """
738 Compare two Filename instances for inequality.
740 :param other: Parameter to compare against.
741 :returns: ``True``, if both filenames are unequal.
742 :raises TypeError: If parameter ``other`` is not of type :class:`Filename`.
743 """
744 if not isinstance(other, Filename):
745 ex = TypeError("Parameter 'other' is not of type Filename.")
746 if version_info >= (3, 11): # pragma: no cover
747 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
748 raise ex
750 return self._name != other._name or self.Size != other.Size
752 def __repr__(self) -> str:
753 return f"File: {self.Path}"
755 def __str__(self) -> str:
756 return self._name
759@export
760class SymbolicLink(Element[Directory]):
761 _target: Path
763 def __init__(
764 self,
765 name: str,
766 target: Path,
767 parent: Nullable[Directory]
768 ) -> None:
769 super().__init__(name, None, parent)
771 self._target = target
773 if parent is not None:
774 parent._symbolicLinks[name] = self
776 if parent._root is not None:
777 self._root = parent._root
779 @readonly
780 def Path(self) -> Path:
781 return self._parent.Path / self._name
783 @readonly
784 def Target(self) -> Path:
785 return self._target
787 def Copy(self, parent: Directory) -> "SymbolicLink":
788 return SymbolicLink(self._name, self._target, parent=parent)
790 def ToTree(self) -> Node:
791 def format(node: Node) -> str:
792 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
794 symbolicLinkNode = Node(
795 value=self,
796 keyValuePairs={
797 "kind": NodeKind.SymbolicLink,
798 "size": self._size
799 },
800 format=format
801 )
803 return symbolicLinkNode
805 def __eq__(self, other) -> bool:
806 """
807 Compare two SymbolicLink instances for equality.
809 :param other: Parameter to compare against.
810 :returns: ``True``, if both symbolic links are equal.
811 :raises TypeError: If parameter ``other`` is not of type :class:`SymbolicLink`.
812 """
813 if not isinstance(other, SymbolicLink):
814 ex = TypeError("Parameter 'other' is not of type SymbolicLink.")
815 if version_info >= (3, 11): # pragma: no cover
816 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
817 raise ex
819 return self._name == other._name and self._target == other._target
821 def __ne__(self, other: Any) -> bool:
822 """
823 Compare two SymbolicLink instances for inequality.
825 :param other: Parameter to compare against.
826 :returns: ``True``, if both symbolic links are unequal.
827 :raises TypeError: If parameter ``other`` is not of type :class:`SymbolicLink`.
828 """
829 if not isinstance(other, SymbolicLink):
830 ex = TypeError("Parameter 'other' is not of type SymbolicLink.")
831 if version_info >= (3, 11): # pragma: no cover
832 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
833 raise ex
835 return self._name != other._name or self._target != other._target
837 def __repr__(self) -> str:
838 return f"SymLink: {self.Path} -> {self._target}"
840 def __str__(self) -> str:
841 return self._name
844@export
845class Root(Directory):
846 _ids: Dict[int, "File"] #: Dictionary of file identifier - file objects pairs found while scanning the directory structure.
848 def __init__(
849 self,
850 rootDirectory: Path,
851 collectSubdirectories: bool = True
852 ) -> None:
853 if rootDirectory is None: 853 ↛ 854line 853 didn't jump to line 854 because the condition on line 853 was never true
854 raise ValueError(f"Parameter 'path' is None.")
855 elif not isinstance(rootDirectory, Path): 855 ↛ 856line 855 didn't jump to line 856 because the condition on line 855 was never true
856 raise TypeError(f"Parameter 'path' is not of type Path.")
857 elif not rootDirectory.exists(): 857 ↛ 858line 857 didn't jump to line 858 because the condition on line 857 was never true
858 raise ToolingException(f"Path '{rootDirectory}' doesn't exist.") from FileNotFoundError(rootDirectory)
860 self._ids = {}
862 super().__init__(rootDirectory.name)
863 self._root = self
864 self._path = rootDirectory
866 if collectSubdirectories: 866 ↛ 867line 866 didn't jump to line 867 because the condition on line 866 was never true
867 self._collectSubdirectories()
868 self._connectSymbolicLinks()
870 @readonly
871 def TotalHardLinkCount(self) -> int:
872 return sum(l for f in self._ids.values() if (l := len(f._parents)) > 1)
874 @readonly
875 def TotalHardLinkCount2(self) -> int:
876 return sum(1 for f in self._ids.values() if len(f._parents) > 1)
878 @readonly
879 def TotalHardLinkCount3(self) -> int:
880 return sum(1 for f in self._ids.values() if len(f._parents) == 1)
882 @readonly
883 def Size2(self) -> int:
884 return sum(f._size for f in self._ids.values() if len(f._parents) > 1)
886 @readonly
887 def Size3(self) -> int:
888 return sum(f._size * len(f._parents) for f in self._ids.values() if len(f._parents) > 1)
890 @readonly
891 def TotalUniqueFileCount(self) -> int:
892 return len(self._ids)
894 @readonly
895 def Path(self) -> Path:
896 """
897 Read-only property to access the path of the filesystem statistics root.
899 :returns: Path to the root of the filesystem statistics root directory.
900 """
901 return self._path
903 def Copy(self) -> "Root":
904 """
905 Copy the directory structure including all subelements and link it to the given parent.
907 The duration for the deep copy process is provided in :attr:`ScanDuration`
909 .. hint::
911 Statistics like aggregated directory size are copied too. |br|
912 There is no rescan or repeated aggregation needed.
914 :returns: A deep copy of the directory structure.
915 """
916 with Stopwatch() as sw:
917 root = Root(self._path, False)
918 root._size = self._size
920 for subdir in self._subdirectories.values():
921 subdir.Copy(root)
923 for file in self._files.values():
924 file.Copy(root)
926 for link in self._symbolicLinks.values():
927 link.Copy(root)
929 root._scanDuration = sw.Duration
930 root._aggregateDuration = 0.0
932 return root
934 def __repr__(self) -> str:
935 return f"Root: {self.Path} (dirs: {self.TotalSubdirectoryCount}, files: {self.TotalRegularFileCount}, symlinks: {self.TotalSymbolicLinkCount})"
937 def __str__(self) -> str:
938 return self._name
941@export
942class File(Base):
943 _id: int
944 _parents: List[Filename]
946 def __init__(
947 self,
948 id: int,
949 size: int,
950 parent: Nullable[Filename] = None
951 ) -> None:
952 self._id = id
953 if parent is None:
954 super().__init__(None, size)
955 self._parents = []
956 else:
957 super().__init__(parent._root, size)
958 self._parents = [parent]
959 parent._file = self
961 @readonly
962 def ID(self) -> int:
963 """
964 Read-only property to access the file object's unique identifier.
966 :returns: Unique file object identifier.
967 """
968 return self._id
970 @readonly
971 def Parents(self) -> List[Filename]:
972 """
973 Read-only property to access the list of filenames using the file object.
975 .. hint::
977 This allows to check if a file object has multiple filenames a.k.a hardlinks.
979 :returns: List of filenames for the file object.
980 """
981 return self._parents
983 def AddParent(self, file: Filename) -> None:
984 if file._file is not None: 984 ↛ 985line 984 didn't jump to line 985 because the condition on line 984 was never true
985 raise ToolingException(f"Filename is already referencing an other file object ({file._file._id}).")
987 self._parents.append(file)
988 file._file = self
990 if file._root is not None:
991 self._root = file._root