Coverage for pyTooling/Filesystem/__init__.py: 55%
607 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-13 00:18 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-13 00: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.
38.. seealso::
40 :mod:`pyTooling.Filesystem.Docker`
41 |rarr| Slicing a scanned filesystem into Docker image layers.
42 :mod:`pyTooling.Tree`
43 |rarr| The tree data structure a filesystem scope is converted to.
44 :mod:`pyTooling.Stopwatch`
45 |rarr| The stopwatch measuring how long a scan took.
46"""
47from __future__ import annotations
49from enum import Enum
50from itertools import chain
51from os import scandir, readlink
52from pathlib import Path
53from typing import Optional as Nullable, Generic, Generator, TypeVar, Any, Callable, Union
54from typing import Iterator, cast
56from pyTooling.Decorators import readonly, export
57from pyTooling.Exceptions import ToolingException
58from pyTooling.MetaClasses import ExtendedType
59from pyTooling.Common import getFullyQualifiedName, zipdicts
60from pyTooling.Warning import WarningCollector, Warning
61from pyTooling.Stopwatch import Stopwatch
62from pyTooling.Tree import Node
65__all__ = ["_ParentType"]
68_ParentType = TypeVar("_ParentType", bound="Element")
69"""The type variable for a parent reference."""
72@export
73class FilesystemError(ToolingException):
74 """Base-exception of all exceptions raised by :mod:`pyTooling.Filesystem`."""
77@export
78class PermissionWarning(Warning):
79 """
80 Warning emitted when a directory or file couldn't be read while scanning a filesystem.
82 The scan continues, so the collected statistics are incomplete by exactly the path this warning carries.
83 """
84 _path: Path #: Path that couldn't be read.
86 def __init__(self, path: Path, *args: Any) -> None:
87 """
88 Initialize a permission warning for the path that couldn't be read.
90 :param path: The path that raised a :exc:`PermissionError`.
91 :param args: Positional parameters forwarded to the base-class.
92 """
93 super().__init__(*args)
94 self._path = path
96 @readonly
97 def Path(self) -> Path:
98 """
99 Read-only property to access the path that couldn't be read (:attr:`_path`).
101 :returns: The path that raised a :exc:`PermissionError`.
102 """
103 return self._path
106@export
107class NodeKind(Enum):
108 """
109 Node kind for filesystem elements in a :ref:`tree <STRUCT/Tree>`.
111 This enumeration is used when converting the filesystem statistics tree to an instance of :mod:`pyTooling.Tree`.
112 """
113 Directory = 0 #: Node represents a directory.
114 File = 1 #: Node represents a regular file.
115 SymbolicLink = 2 #: Node represents a symbolic link.
118@export
119class Base(metaclass=ExtendedType, slots=True):
120 """
121 Base-class for all filesystem elements in :mod:`pyTooling.Filesystem`.
123 It implements a size and a reference to the root element of the filesystem.
124 """
125 _root: Nullable[Root] #: Reference to the root of the filesystem statistics scope.
126 _size: Nullable[int] #: Actual or aggregated size of the filesystem element.
128 def __init__(
129 self,
130 size: Nullable[int],
131 root: Nullable[Root]
132 ) -> None:
133 """
134 Initialize the base-class with filesystem element size and root reference.
136 :param size: Optional, size of the element.
137 :param root: Optional reference to the filesystem root element.
138 :raises TypeError: If parameter 'size' is not of type integer.
139 :raises TypeError: If parameter 'root' is not of type :class:`Root`.
140 """
141 if size is not None and not isinstance(size, int): 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 ex = TypeError("Parameter 'size' is not of type 'int'.")
143 ex.add_note(f"Got type '{getFullyQualifiedName(size)}'.")
144 raise ex
146 if root is not None and not isinstance(root, Root): 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 ex = TypeError("Parameter 'root' is not of type 'Root'.")
148 ex.add_note(f"Got type '{getFullyQualifiedName(root)}'.")
149 raise ex
151 self._size = size
152 self._root = root
154 @property
155 def Root(self) -> Nullable[Root]:
156 """
157 Property to access the root of the filesystem statistics scope.
159 :returns: Root of the filesystem statistics scope.
160 :raises ValueError: If ``None`` is assigned.
161 :raises TypeError: If an assigned value is not of type :class:`Root`.
162 """
163 return self._root
165 @Root.setter
166 def Root(self, value: Root) -> None:
167 if value is None: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 raise ValueError("Parameter 'value' is None.")
169 elif not isinstance(value, Root): 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
170 ex = TypeError("Parameter 'value' is not of type 'Root'.")
171 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
172 raise ex
174 self._root = value
176 @readonly
177 def Size(self) -> int:
178 """
179 Read-only property to access the element's size in Bytes.
181 :returns: Size in Bytes.
182 :raises FilesystemError: If size is not computed, yet.
183 """
184 if self._size is None: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true
185 raise FilesystemError("Size is not computed, yet.")
187 return self._size
189 # FIXME: @abstractmethod
190 def ToTree(self) -> Node:
191 """
192 Convert a filesystem element to a node in :mod:`pyTooling.Tree`.
194 The node's :attr:`~pyTooling.Tree.Node.Value` field contains a reference to the filesystem element. Additional data
195 will be stored in the node's key-value store.
197 :returns: A tree's node referencing this filesystem element.
198 """
199 raise NotImplementedError()
202@export
203class Element(Base, Generic[_ParentType]):
204 """
205 Base-class for all named elements within a filesystem.
207 It adds a name, parent reference and list of symbolic-link sources.
209 .. hint::
211 Symbolic link sources are reverse references describing which symbolic links point to this element.
212 """
213 _name: str #: Name of the filesystem element.
214 _parent: _ParentType #: Reference to the filesystem element's parent (:class:`Directory`)
215 _linkSources: list[SymbolicLink] #: A list of symbolic links pointing to this filesystem element.
217 def __init__(
218 self,
219 name: str,
220 size: Nullable[int] = None,
221 parent: Nullable[_ParentType] = None
222 ) -> None:
223 """
224 Initialize the element base-class with name, size and parent reference.
226 :param name: Name of the element.
227 :param size: Optional, size of the element.
228 :param parent: Optional, parent reference.
229 :raises ValueError: If parameter 'name' is None.
230 :raises TypeError: If parameter 'parent' is not of type :class:`Directory`.
231 """
232 if name is None: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true
233 raise ValueError("Parameter 'name' is None.")
234 elif not isinstance(name, str): 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true
235 ex = TypeError("Parameter 'name' is not of type 'str'.")
236 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
237 raise ex
239 self._name = name
241 if parent is None:
242 super().__init__(size, None)
243 self._parent = None
244 else:
245 if not isinstance(parent, Directory): 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 ex = TypeError("Parameter 'parent' is not of type 'Directory'.")
247 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
248 raise ex
250 super().__init__(size, parent._root)
251 self._parent = parent
253 self._linkSources = []
255 @property
256 def Parent(self) -> _ParentType:
257 """
258 Property to access the element's parent.
260 :returns: Parent element.
261 :raises ValueError: If ``None`` is assigned.
262 :raises TypeError: If an assigned value is not of type :class:`Directory`.
263 """
264 return self._parent
266 @Parent.setter
267 def Parent(self, value: _ParentType) -> None:
268 if value is None: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true
269 raise ValueError("Parameter 'value' is None.")
270 elif not isinstance(value, Directory): 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true
271 ex = TypeError("Parameter 'value' is not of type 'Directory'.")
272 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
273 raise ex
275 self._parent = value
277 if value._root is not None:
278 self._root = value._root
280 @readonly
281 def Name(self) -> str:
282 """
283 Read-only property to access the element's name.
285 :returns: Element name.
286 """
287 return self._name
289 @readonly
290 def Path(self) -> Path:
291 """
292 Read-only property to access the element's path.
294 :returns: Path of the element.
295 """
296 raise NotImplementedError("Property 'Path' is abstract.")
298 @readonly
299 def LinkSources(self) -> list[SymbolicLink]:
300 """
301 Read-only property to access the symbolic links pointing to this element (:attr:`_linkSources`).
303 :returns: List of symbolic links targeting this element.
304 """
305 return self._linkSources
307 def AddLinkSources(self, source: SymbolicLink) -> None:
308 """
309 Add a link source of a symbolic link to the named element (reverse reference).
311 :param source: The referenced symbolic link.
312 :raises TypeError: If parameter 'source' is not of type :class:`SymbolicLink`.
313 """
314 if not isinstance(source, SymbolicLink):
315 ex = TypeError("Parameter 'source' is not of type 'SymbolicLink'.")
316 ex.add_note(f"Got type '{getFullyQualifiedName(source)}'.")
317 raise ex
319 source._isConnected = True
320 source._isBroken = False
321 source._isOutOfRange = False
322 self._linkSources.append(source)
325@export
326class Directory(Element["Directory"]):
327 """
328 A **directory** represents a directory in the filesystem, which contains subdirectories, regular files and symbolic links.
330 While scanning for subelements, the directory is populated with elements. Every file object added, gets registered in
331 the filesystems :class:`Root` for deduplication. In case a file identifier already exists, the found filename will
332 reference the same file objects. In turn, the file objects has then references to multiple filenames (parents). This
333 allows to detect :term:`hardlinks <hardlink>`.
335 The time needed for scanning the directory and its subelements is provided via :data:`ScanDuration`.
337 After scnaning the directory for subelements, certain directory properties get aggregated. The time needed for
338 aggregation is provided via :data:`AggregateDuration`.
339 """
341 _path: Nullable[Path] #: Cached :class:`~pathlib.Path` object of this directory.
342 _subdirectories: dict[str, Directory] #: Dictionary containing name-:class:`Directory` pairs.
343 _files: dict[str, Filename] #: Dictionary containing name-:class:`Filename` pairs.
344 _symbolicLinks: dict[str, SymbolicLink] #: Dictionary containing name-:class:`SymbolicLink` pairs.
345 _filesSize: int #: Aggregated size of all direct files.
346 _collapsed: bool #: True, if this directory was collapsed. It contains no subelements.
347 _scanDuration: Nullable[float] #: Duration for scanning the directory and all its subelements.
348 _aggregateDuration: Nullable[float] #: Duration for aggregating all subelements.
350 def __init__(
351 self,
352 name: str,
353 collectSubdirectories: bool = False,
354 parent: Nullable[Directory] = None
355 ) -> None:
356 """
357 Initialize the directory with name and parent reference.
359 :param name: Name of the element.
360 :param collectSubdirectories: Optional, if ``True``, collect subdirectory statistics.
361 :param parent: Optional, parent reference.
362 """
363 super().__init__(name, None, parent)
365 self._path = None
366 self._subdirectories = {}
367 self._files = {}
368 self._symbolicLinks = {}
369 self._filesSize = 0
370 self._collapsed = False
371 self._scanDuration = None
372 self._aggregateDuration = None
374 if parent is not None:
375 parent._subdirectories[name] = self
377 if parent._root is not None: 377 ↛ 380line 377 didn't jump to line 380 because the condition on line 377 was always true
378 self._root = parent._root
380 if collectSubdirectories:
381 self.CollectSubdirectories()
383 def CollectSubdirectories(self) -> None:
384 """
385 Helper method for scanning subdirectories and aggregating found element sizes therein.
386 """
387 self.ScanSubdirectories()
388 self.AggregateSizes()
390 def ScanSubdirectories(self) -> None:
391 """
392 Helper method for scanning subdirectories (recursively) and building a
393 :class:`Directory`-:class:`Filename`-:class:`File` object tree.
395 If a file refers to the same filesystem internal unique ID, a hardlink (two or more filenames) to the same file
396 storage object is assumed.
398 A directory that can't be read is reported as a :class:`PermissionWarning` and skipped, so the scan continues and
399 the collected statistics are incomplete by exactly that path.
401 :raises FilesystemError: If this directory isn't attached to a :class:`Root`, which owns the ID table.
402 :raises FilesystemError: If the directory contains an element that is neither a directory, a file nor a
403 symbolic link.
404 """
405 if (root := self._root) is None:
406 raise FilesystemError(f"Directory '{self._name}' is not attached to a filesystem root.")
408 with Stopwatch() as sw1:
409 try:
410 items = scandir(directoryPath := self.Path)
411 except PermissionError as ex:
412 WarningCollector.Raise(PermissionWarning(self.Path), ex)
413 return
415 for dirEntry in items:
416 if dirEntry.is_dir(follow_symlinks=False):
417 _ = Directory(dirEntry.name, collectSubdirectories=True, parent=self)
418 elif dirEntry.is_file(follow_symlinks=False): 418 ↛ 430line 418 didn't jump to line 430 because the condition on line 418 was always true
419 id = dirEntry.inode()
420 if id in root._ids: 420 ↛ 421line 420 didn't jump to line 421 because the condition on line 420 was never true
421 file = root._ids[id]
423 _ = Filename(dirEntry.name, file=file, parent=self)
424 else:
425 s = dirEntry.stat(follow_symlinks=False)
426 filename = Filename(dirEntry.name, parent=self)
427 file = File(id, s.st_size, parent=filename)
429 root._ids[id] = file
430 elif dirEntry.is_symlink():
431 target = Path(readlink(directoryPath / dirEntry.name))
432 _ = SymbolicLink(dirEntry.name, target, parent=self)
433 else:
434 raise FilesystemError("Unknown directory element.")
436 self._scanDuration = sw1.Duration
438 def ResolveSymbolicLinks(self) -> None:
439 """
440 Resolve the symbolic links of this directory and of every directory below it.
442 A link whose target lies inside the scanned tree is connected to that element; a target that doesn't exist
443 registers the link as broken, and a target outside the scanned tree registers it as unconnected.
444 """
445 for dir in self._subdirectories.values():
446 dir.ResolveSymbolicLinks()
448 for link in self._symbolicLinks.values(): 448 ↛ 449line 448 didn't jump to line 449 because the loop on line 448 never started
449 if link._target.is_absolute():
450 # todo: resolve path and check if target is in range, otherwise add to out-of-range list
451 pass
452 else:
453 target = self
454 for elem in link._target.parts:
455 if elem == ".":
456 continue
457 elif elem == "..":
458 if (target := target._parent) is None:
459 self._root.RegisterUnconnectedSymbolicLink(link)
460 break
462 continue
464 try:
465 target = target._subdirectories[elem]
466 continue
467 except KeyError:
468 pass
470 try:
471 target = target._files[elem]
472 continue
473 except KeyError:
474 pass
476 try:
477 target = target._symbolicLinks[elem]
478 continue
479 except KeyError:
480 self._root.RegisterBrokenSymbolicLink(link)
481 break
482 else:
483 target.AddLinkSources(link)
485 def AggregateSizes(self) -> set[File]:
486 """
487 Compute the aggregated size of this directory and of every directory below it.
489 A file is counted once, even when several filenames (hardlinks) refer to it, which is why the already counted
490 files are returned and handed up the recursion.
492 :returns: The set of file objects counted in this subtree.
493 """
494 with Stopwatch() as sw2:
495 aggregatedFiles = set()
497 self._size = 0
498 self._filesSize = 0
499 for dir in self._subdirectories.values():
500 aggregatedFiles |= dir.AggregateSizes()
501 self._size += dir._size
503 for filename in self._files.values():
504 if (file := filename._file) not in aggregatedFiles: 504 ↛ 503line 504 didn't jump to line 503 because the condition on line 504 was always true
505 self._filesSize += file._size
506 aggregatedFiles.add(file)
508 self._size += self._filesSize
510 self._aggregateDuration = sw2.Duration
512 return aggregatedFiles
514 @Element.Root.setter
515 def Root(self, value: Root) -> None:
516 Element.Root.fset(self, value)
518 for subdir in self._subdirectories.values(): 518 ↛ 519line 518 didn't jump to line 519 because the loop on line 518 never started
519 subdir.Root = value
521 for file in self._files.values():
522 file.Root = value
524 for link in self._symbolicLinks.values(): 524 ↛ 525line 524 didn't jump to line 525 because the loop on line 524 never started
525 link.Root = value
527 @Element.Parent.setter
528 def Parent(self, value: _ParentType) -> None:
529 Element.Parent.fset(self, value)
531 value._subdirectories[self._name] = self
533 if isinstance(value, Root): 533 ↛ exitline 533 didn't return from function 'Parent' because the condition on line 533 was always true
534 self.Root = value
536 @readonly
537 def Count(self) -> int:
538 """
539 Read-only property to return the number of elements in a directory.
541 :returns: Number of files plus subdirectories.
542 """
543 return len(self._subdirectories) + len(self._files) + len(self._symbolicLinks)
545 @readonly
546 def FileCount(self) -> int:
547 """
548 Read-only property to return the number of files in a directory.
550 .. hint::
552 Files include regular files and symbolic links.
554 :returns: Number of files.
555 """
556 return len(self._files) + len(self._symbolicLinks)
558 @readonly
559 def RegularFileCount(self) -> int:
560 """
561 Read-only property to return the number of regular files in a directory.
563 :returns: Number of regular files.
564 """
565 return len(self._files)
567 @readonly
568 def SymbolicLinkCount(self) -> int:
569 """
570 Read-only property to return the number of symbolic links in a directory.
572 :returns: Number of symbolic links.
573 """
574 return len(self._symbolicLinks)
576 @readonly
577 def SubdirectoryCount(self) -> int:
578 """
579 Read-only property to return the number of subdirectories in a directory.
581 :returns: Number of subdirectories.
582 """
583 return len(self._subdirectories)
585 @readonly
586 def TotalFileCount(self) -> int:
587 """
588 Read-only property to return the total number of files in all child hierarchy levels (recursively).
590 .. hint::
592 Files include regular files and symbolic links.
594 :returns: Total number of files.
595 """
596 return sum(d.TotalFileCount for d in self._subdirectories.values()) + len(self._files) + len(self._symbolicLinks)
598 @readonly
599 def TotalRegularFileCount(self) -> int:
600 """
601 Read-only property to return the total number of regular files in all child hierarchy levels (recursively).
603 :returns: Total number of regular files.
604 """
605 return sum(d.TotalRegularFileCount for d in self._subdirectories.values()) + len(self._files)
607 @readonly
608 def TotalSymbolicLinkCount(self) -> int:
609 """
610 Read-only property to return the total number of symbolic links in all child hierarchy levels (recursively).
612 :returns: Total number of symbolic links.
613 """
614 return sum(d.TotalSymbolicLinkCount for d in self._subdirectories.values()) + len(self._symbolicLinks)
616 @readonly
617 def TotalSubdirectoryCount(self) -> int:
618 """
619 Read-only property to return the total number of subdirectories in all child hierarchy levels (recursively).
621 :returns: Total number of subdirectories.
622 """
623 return len(self._subdirectories) + sum(d.TotalSubdirectoryCount for d in self._subdirectories.values())
625 @readonly
626 def Subdirectories(self) -> Generator[Directory, None, None]:
627 """
628 Iterate all direct subdirectories of the directory.
630 :returns: A generator to iterate all direct subdirectories.
631 """
632 return (d for d in self._subdirectories.values())
634 @readonly
635 def Files(self) -> Generator[Filename | SymbolicLink, None, None]:
636 """
637 Iterate all direct files of the directory.
639 .. hint::
641 Files include regular files and symbolic links.
643 :returns: A generator to iterate all direct files.
644 """
645 return (f for f in chain(self._files.values(), self._symbolicLinks.values()))
647 @readonly
648 def RegularFiles(self) -> Generator[Filename, None, None]:
649 """
650 Iterate all direct regular files of the directory.
652 :returns: A generator to iterate all direct regular files.
653 """
654 return (f for f in self._files.values())
656 @readonly
657 def SymbolicLinks(self) -> Generator[SymbolicLink, None, None]:
658 """
659 Iterate all direct symbolic links of the directory.
661 :returns: A generator to iterate all direct symbolic links.
662 """
663 return (l for l in self._symbolicLinks.values())
665 @readonly
666 def Path(self) -> Path:
667 """
668 Read-only property to access the equivalent Path instance for accessing the represented directory.
670 :returns: Path to the directory.
671 :raises FilesystemError: If no parent is set.
672 """
673 if self._path is not None:
674 return self._path
676 if self._parent is None: 676 ↛ 677line 676 didn't jump to line 677 because the condition on line 676 was never true
677 raise FilesystemError("No parent or root set for directory.")
679 self._path = self._parent.Path / self._name
680 return self._path
682 @readonly
683 def ScanDuration(self) -> float:
684 """
685 Read-only property to access the time needed to scan a directory structure including all subelements (recursively).
687 :returns: The scan duration in seconds.
688 :raises FilesystemError: If the directory was not scanned.
689 """
690 if self._scanDuration is None:
691 raise FilesystemError("Directory was not scanned, yet.")
693 return self._scanDuration
695 @readonly
696 def AggregateDuration(self) -> float:
697 """
698 Read-only property to access the time needed to aggregate the directory's and subelement's properties (recursively).
700 :returns: The aggregation duration in seconds.
701 :raises FilesystemError: If the directory properties were not aggregated.
702 """
703 if self._scanDuration is None:
704 raise FilesystemError("Directory properties were not aggregated, yet.")
706 return self._aggregateDuration
708 def __hash__(self) -> int:
709 """
710 Compute a hash for this filesystem element based on its identity.
712 Two elements with the same name in different directories are different elements, so the hash is derived from the
713 object's identity and not from its name.
715 :returns: Hash of this filesystem element.
716 """
717 return hash(id(self))
719 def IterateDirectories(self) -> Generator[Directory, None, None]:
720 """
721 A generator to iterate all subdirectories below this directory in pre-order.
723 A parent directory is yielded before its children.
725 :returns: A generator to iterate all subdirectories below this directory.
726 """
727 # pre-order
728 for directory in self._subdirectories.values():
729 yield directory
730 yield from directory.IterateDirectories()
732 def IterateFiles(self) -> Generator[Element, None, None]:
733 """
734 A generator to iterate all files and symbolic links below this directory in post-order.
736 The elements of the subdirectories are yielded before this directory's own.
738 :returns: A generator to iterate all files and symbolic links below this directory.
739 """
740 # post-order
741 for directory in self._subdirectories.values():
742 yield from directory.IterateFiles()
744 yield from self._files.values()
745 yield from self._symbolicLinks.values()
747 def Copy(self, parent: Nullable[Directory] = None) -> Directory:
748 """
749 Copy the directory structure including all subelements and link it to the given parent.
751 .. hint::
753 Statistics like aggregated directory size are copied too. |br|
754 There is no rescan or repeated aggregation needed.
756 :param parent: Optional, the parent element of the copied directory.
757 :returns: A deep copy of the directory structure.
758 """
759 dir = Directory(self._name, parent=parent)
760 dir._size = self._size
762 for subdir in self._subdirectories.values():
763 subdir.Copy(dir)
765 for file in self._files.values():
766 file.Copy(dir)
768 for link in self._symbolicLinks.values():
769 link.Copy(dir)
771 return dir
773 def Collapse(self, func: Callable[[Directory], bool]) -> bool:
774 """
775 Collapse this directory's subtree where the given predicate accepts it.
777 A directory is collapsed when it has no subdirectories left - or all of them collapsed - and the predicate
778 accepts it. Collapsing discards the directory's elements, so only its aggregated numbers remain.
780 :param func: Predicate deciding whether a directory may be collapsed.
781 :returns: ``True``, if this directory was collapsed.
782 """
783 # if len(self._subdirectories) == 0 or all(subdir.Collapse(func) for subdir in self._subdirectories.values()):
784 if len(self._subdirectories) == 0:
785 if func(self):
786 # print(f"collapse 1 {self.Path}")
787 self._collapsed = True
788 self._subdirectories.clear()
789 self._files.clear()
790 self._symbolicLinks.clear()
792 return True
793 else:
794 return False
796 # if all(subdir.Collapse(func) for subdir in self._subdirectories.values())
797 collapsible = True
798 for subdir in self._subdirectories.values():
799 result = subdir.Collapse(func)
800 collapsible = collapsible and result
802 if collapsible:
803 # print(f"collapse 2 {self.Path}")
804 self._collapsed = True
805 self._subdirectories.clear()
806 self._files.clear()
807 self._symbolicLinks.clear()
809 return True
810 else:
811 return False
813 def ToTree(self, format: Nullable[Callable[[Node], str]] = None) -> Node:
814 """
815 Convert the directory to a :class:`~pyTooling.Tree.Node`.
817 The node's :attr:`~pyTooling.Tree.Node.Value` field contains a reference to the directory. Additional data is
818 attached to the node's key-value store:
820 ``kind``
821 The node's kind. See :class:`NodeKind`.
822 ``size``
823 The directory's aggregated size.
825 :param format: Optional, a user defined formatting function for tree nodes.
826 :returns: A tree node representing this directory.
827 """
828 if format is None:
829 def format(node: Node) -> str:
830 """
831 Nested function rendering a tree node as one line.
833 :param node: The tree node to render.
834 :returns: The node's size in MiB, followed by its name.
835 """
836 element = cast(Element[Any], node._value) # the node was created with this element as its value
837 return f"{node['size'] * 1e-6:7.1f} MiB {element.Name}"
839 directoryNode: Node[Any, Any, Any, Any] = Node(
840 value=self,
841 keyValuePairs={
842 "kind": NodeKind.File,
843 "size": self._size
844 },
845 format=format
846 )
847 directoryNode.AddChildren(
848 e.ToTree(format) for e in chain(self._subdirectories.values()) #, self._files.values(), self._symbolicLinks.values())
849 )
851 return directoryNode
853 def __eq__(self, other: Any) -> bool:
854 """
855 Compare two Directory instances for equality.
857 :param other: Parameter to compare against.
858 :returns: ``True``, if both directories and all its subelements are equal.
859 :raises TypeError: If parameter ``other`` is not of type :class:`Directory`.
860 """
861 if not isinstance(other, Directory):
862 ex = TypeError("Parameter 'other' is not of type Directory.")
863 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
864 raise ex
866 if not all(dir1 == dir2 for _, dir1, dir2 in zipdicts(self._subdirectories, other._subdirectories)):
867 return False
869 if not all(file1 == file2 for _, file1, file2 in zipdicts(self._files, other._files)):
870 return False
872 if not all(link1 == link2 for _, link1, link2 in zipdicts(self._symbolicLinks, other._symbolicLinks)):
873 return False
875 return True
877 def __ne__(self, other: Any) -> bool:
878 """
879 Compare two Directory instances for inequality.
881 :param other: Parameter to compare against.
882 :returns: ``True``, if both directories and all its subelements are unequal.
883 :raises TypeError: If parameter ``other`` is not of type :class:`Directory`.
884 """
885 return not self.__eq__(other)
887 def __repr__(self) -> str:
888 """
889 Return a detailed string representation of this directory.
891 :returns: The directory's full path, prefixed by its kind.
892 """
893 return f"Directory: {self.Path}"
895 def __str__(self) -> str:
896 """
897 Return a string representation of this filesystem element.
899 :returns: The element's name, without any path.
900 """
901 return self._name
904@export
905class Filename(Element[Directory]):
906 """
907 Represents a filename in the filesystem, but not the file storage object (:class:`File`).
909 .. hint::
911 Filename and file storage are represented by two classes, which allows multiple names (hard links) per file storage
912 object.
913 """
914 _file: Nullable[File] #: The file this filename refers to; ``None`` until the filename is linked.
916 def __init__(
917 self,
918 name: str,
919 file: Nullable[File] = None,
920 parent: Nullable[Directory] = None
921 ) -> None:
922 """
923 Initialize the filename with name, file (storage) object and parent reference.
925 :param name: Name of the file.
926 :param file: Optional, file (storage) object.
927 :param parent: Optional, parent reference.
928 :raises TypeError: If parameter 'file' is not of type :class:`File`.
929 """
930 super().__init__(name, None, parent)
932 if file is None: 932 ↛ 935line 932 didn't jump to line 935 because the condition on line 932 was always true
933 self._file = None
934 else:
935 if not isinstance(file, File):
936 ex = TypeError("Parameter 'file' is not of type 'File'.")
937 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.")
938 raise ex
940 self._file = file
941 file._parents.append(self)
943 if parent is not None:
944 parent._files[name] = self
946 if parent._root is not None:
947 self._root = parent._root
949 @Element.Root.setter
950 def Root(self, value: Root) -> None:
951 Element.Root.fset(self, value)
953 if self._file is not None: 953 ↛ exitline 953 didn't return from function 'Root' because the condition on line 953 was always true
954 self._file.Root = value
956 @Element.Parent.setter
957 def Parent(self, value: _ParentType) -> None:
958 Element.Parent.fset(self, value)
960 value._files[self._name] = self
962 if isinstance(value, Root): 962 ↛ 963line 962 didn't jump to line 963 because the condition on line 962 was never true
963 self.Root = value
965 @readonly
966 def File(self) -> Nullable[File]:
967 """
968 Read-only property to access the file this filename is linked to (:attr:`_file`).
970 :returns: The linked file, or ``None`` if the filename isn't linked yet.
971 """
972 return self._file
974 @readonly
975 def Size(self) -> int:
976 """
977 Read-only property to access the size of the linked file.
979 :returns: Size of the linked file in bytes.
980 :raises ToolingException: If the filename isn't linked to a file object.
981 """
982 if self._file is None:
983 raise ToolingException("Filename isn't linked to a File object.")
985 return self._file._size
987 @readonly
988 def Path(self) -> Path:
989 """
990 Read-only property to return the filename's absolute path.
992 The path is computed from the parent directory's path and the filename.
994 :returns: Absolute path of the file.
995 :raises ToolingException: If the filename has no parent object.
996 """
997 if self._parent is None:
998 raise ToolingException("Filename has no parent object.")
1000 return self._parent.Path / self._name
1002 def __hash__(self) -> int:
1003 """
1004 Compute a hash for this filesystem element based on its identity.
1006 Two elements with the same name in different directories are different elements, so the hash is derived from the
1007 object's identity and not from its name.
1009 :returns: Hash of this filesystem element.
1010 """
1011 return hash(id(self))
1013 def Copy(self, parent: Directory) -> Filename:
1014 """
1015 Copy this filename into another filesystem statistics scope.
1017 The file object behind the filename is copied only once per scope: a filename referring to a file that was
1018 already copied - a hardlink - is connected to the existing copy.
1020 :param parent: Optional, the directory in the target scope the copy is registered at.
1021 :returns: The copied filename.
1022 """
1023 fileID = self._file._id
1025 if fileID in parent._root._ids:
1026 file = parent._root._ids[fileID]
1027 else:
1028 fileSize = self._file._size
1029 file = File(fileID, fileSize)
1031 parent._root._ids[fileID] = file
1033 return Filename(self._name, file, parent=parent)
1035 def ToTree(self) -> Node:
1036 """
1037 Convert this filename to a node of a :mod:`pyTooling.Tree`.
1039 :returns: A tree node carrying this filename, its kind and its size.
1040 """
1041 def format(node: Node) -> str:
1042 """
1043 Nested function rendering a tree node as one line.
1045 :param node: The tree node to render.
1046 :returns: The node's size in MiB, followed by its name.
1047 """
1048 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
1050 fileNode: Node[Any, Any, Any, Any] = Node(
1051 value=self,
1052 keyValuePairs={
1053 "kind": NodeKind.File,
1054 "size": self._size
1055 },
1056 format=format
1057 )
1059 return fileNode
1061 def __eq__(self, other: Any) -> bool:
1062 """
1063 Compare two Filename instances for equality.
1065 :param other: Parameter to compare against.
1066 :returns: ``True``, if both filenames are equal.
1067 :raises TypeError: If parameter ``other`` is not of type :class:`Filename`.
1068 """
1069 if not isinstance(other, Filename):
1070 ex = TypeError("Parameter 'other' is not of type 'Filename'.")
1071 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
1072 raise ex
1074 return self._name == other._name and self.Size == other.Size
1076 def __ne__(self, other: Any) -> bool:
1077 """
1078 Compare two Filename instances for inequality.
1080 :param other: Parameter to compare against.
1081 :returns: ``True``, if both filenames are unequal.
1082 :raises TypeError: If parameter ``other`` is not of type :class:`Filename`.
1083 """
1084 if not isinstance(other, Filename):
1085 ex = TypeError("Parameter 'other' is not of type 'Filename'.")
1086 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
1087 raise ex
1089 return self._name != other._name or self.Size != other.Size
1091 def __repr__(self) -> str:
1092 """
1093 Return a detailed string representation of this filename.
1095 :returns: The file's full path, prefixed by its kind.
1096 """
1097 return f"File: {self.Path}"
1099 def __str__(self) -> str:
1100 """
1101 Return a string representation of this filesystem element.
1103 :returns: The element's name, without any path.
1104 """
1105 return self._name
1108@export
1109class SymbolicLink(Element[Directory]):
1110 """
1111 A symbolic link in the filesystem statistics scope.
1113 After the scan, the link is resolved: it is either connected to an element of the scanned tree, broken (the target
1114 doesn't exist), or out of range (the target lies outside the scanned tree).
1115 """
1116 _target: Path #: Path the symbolic link points to.
1117 _isConnected: bool #: ``True``, if the link target was resolved to an element of the scanned tree.
1118 _isBroken: Nullable[bool] #: ``True``, if the link target doesn't exist; ``None`` until resolved.
1119 _isOutOfRange: Nullable[bool] #: ``True``, if the link target lies outside the scanned tree; ``None`` until resolved.
1121 def __init__(
1122 self,
1123 name: str,
1124 target: Path,
1125 parent: Nullable[Directory]
1126 ) -> None:
1127 """
1128 Initialize a symbolic link, which is registered at its parent directory.
1130 The link is unresolved at first: :meth:`Root.ResolveSymbolicLinks` decides afterwards whether it is connected,
1131 broken or out of range.
1133 :param name: Name of the symbolic link.
1134 :param target: Path the symbolic link points to.
1135 :param parent: Optional, parent directory of the symbolic link.
1136 :raises ValueError: If parameter 'target' is None.
1137 :raises TypeError: If parameter 'target' is not of type :class:`~pathlib.Path`.
1138 """
1139 super().__init__(name, None, parent)
1141 if target is None:
1142 raise ValueError("Parameter 'target' is None.")
1143 elif not isinstance(target, Path):
1144 ex = TypeError("Parameter 'target' is not of type 'Path'.")
1145 ex.add_note(f"Got type '{getFullyQualifiedName(target)}'.")
1146 raise ex
1148 self._target = target
1149 self._isConnected = False
1150 self._isBroken = None
1151 self._isOutOfRange = None
1153 if parent is not None:
1154 parent._symbolicLinks[name] = self
1156 if parent._root is not None:
1157 self._root = parent._root
1159 @readonly
1160 def Path(self) -> Path:
1161 """
1162 Read-only property to return the symbolic link's path.
1164 The path is computed from the parent directory's path and the link's name.
1166 :returns: Path of the symbolic link.
1167 """
1168 return self._parent.Path / self._name
1170 @readonly
1171 def Target(self) -> Path:
1172 """
1173 Read-only property to access the path this symbolic link points to (:attr:`_target`).
1175 :returns: Target path of the symbolic link.
1176 """
1177 return self._target
1179 @readonly
1180 def IsConnected(self) -> bool:
1181 """
1182 Check if the symbolic link was resolved to an element within the scanned filesystem (:attr:`_isConnected`).
1184 :returns: ``True``, if the link's target was found and connected.
1185 """
1186 return self._isConnected
1188 @readonly
1189 def IsBroken(self) -> Nullable[bool]:
1190 """
1191 Check if the symbolic link points to a non-existing target (:attr:`_isBroken`).
1193 :returns: ``True``, if the target doesn't exist. ``None``, if the link wasn't resolved yet.
1194 """
1195 return self._isBroken
1197 @readonly
1198 def IsOutOfRange(self) -> Nullable[bool]:
1199 """
1200 Check if the symbolic link points outside the scanned filesystem (:attr:`_isOutOfRange`).
1202 :returns: ``True``, if the target lies outside the scanned root. ``None``, if the link wasn't resolved yet.
1203 """
1204 return self._isOutOfRange
1206 def __hash__(self) -> int:
1207 """
1208 Compute a hash for this filesystem element based on its identity.
1210 Two elements with the same name in different directories are different elements, so the hash is derived from the
1211 object's identity and not from its name.
1213 :returns: Hash of this filesystem element.
1214 """
1215 return hash(id(self))
1217 def Copy(self, parent: Directory) -> SymbolicLink:
1218 """
1219 Copy this symbolic link into another filesystem statistics scope.
1221 :param parent: Optional, the directory in the target scope the copy is registered at.
1222 :returns: The copied symbolic link, unresolved.
1223 """
1224 return SymbolicLink(self._name, self._target, parent=parent)
1226 def ToTree(self) -> Node:
1227 """
1228 Convert this symbolic link to a node of a :mod:`pyTooling.Tree`.
1230 :returns: A tree node carrying this symbolic link, its kind and its size.
1231 """
1232 def format(node: Node) -> str:
1233 """
1234 Nested function rendering a tree node as one line.
1236 :param node: The tree node to render.
1237 :returns: The node's size in MiB, followed by its name.
1238 """
1239 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
1241 symbolicLinkNode: Node[Any, Any, Any, Any] = Node(
1242 value=self,
1243 keyValuePairs={
1244 "kind": NodeKind.SymbolicLink,
1245 "size": self._size
1246 },
1247 format=format
1248 )
1250 return symbolicLinkNode
1252 def __eq__(self, other: Any) -> bool:
1253 """
1254 Compare two SymbolicLink instances for equality.
1256 :param other: Parameter to compare against.
1257 :returns: ``True``, if both symbolic links are equal.
1258 :raises TypeError: If parameter ``other`` is not of type :class:`SymbolicLink`.
1259 """
1260 if not isinstance(other, SymbolicLink):
1261 ex = TypeError("Parameter 'other' is not of type 'SymbolicLink'.")
1262 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
1263 raise ex
1265 return self._name == other._name and self._target == other._target
1267 def __ne__(self, other: Any) -> bool:
1268 """
1269 Compare two SymbolicLink instances for inequality.
1271 :param other: Parameter to compare against.
1272 :returns: ``True``, if both symbolic links are unequal.
1273 :raises TypeError: If parameter ``other`` is not of type :class:`SymbolicLink`.
1274 """
1275 if not isinstance(other, SymbolicLink):
1276 ex = TypeError("Parameter 'other' is not of type 'SymbolicLink'.")
1277 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
1278 raise ex
1280 return self._name != other._name or self._target != other._target
1282 def __repr__(self) -> str:
1283 """
1284 Return a detailed string representation of this symbolic link.
1286 :returns: The link's full path and the path it points to.
1287 """
1288 return f"SymLink: {self.Path} -> {self._target}"
1290 def __str__(self) -> str:
1291 """
1292 Return a string representation of this filesystem element.
1294 :returns: The element's name, without any path.
1295 """
1296 return self._name
1299@export
1300class Root(Directory):
1301 """
1302 A **Root** represents the root-directory in the filesystem, which contains subdirectories, regular files and symbolic links.
1303 """
1304 _ids: dict[int, File] #: Dictionary of file identifier - file objects pairs found while scanning the directory structure.
1305 _brokenSymbolicLinks: list[SymbolicLink] #: Broken symbolic links (target doesn't exist).
1306 _unconnectedSymbolicLinks: list[SymbolicLink] #: Symbolic links which couldn't be connected to their target (out of scope).
1308 def __init__(
1309 self,
1310 rootDirectory: Path,
1311 collectSubdirectories: bool = True
1312 ) -> None:
1313 """
1314 Initialize a filesystem statistics scope for the given directory.
1316 Unless ``collectSubdirectories`` is disabled, the whole tree is scanned and its symbolic links are resolved right
1317 away, so the root is usable as soon as it exists.
1319 :param rootDirectory: Directory to collect the statistics for.
1320 :param collectSubdirectories: Optional, if ``True``, scan the tree and resolve its symbolic links immediately.
1321 :raises ValueError: If parameter 'rootDirectory' is None.
1322 :raises TypeError: If parameter 'rootDirectory' is not of type :class:`~pathlib.Path`.
1323 :raises ToolingException: If the given path doesn't exist.
1324 """
1325 if rootDirectory is None: 1325 ↛ 1326line 1325 didn't jump to line 1326 because the condition on line 1325 was never true
1326 raise ValueError("Parameter 'rootDirectory' is None.")
1327 elif not isinstance(rootDirectory, Path): 1327 ↛ 1328line 1327 didn't jump to line 1328 because the condition on line 1327 was never true
1328 raise TypeError("Parameter 'rootDirectory' is not of type 'Path'.")
1329 elif not rootDirectory.exists(): 1329 ↛ 1330line 1329 didn't jump to line 1330 because the condition on line 1329 was never true
1330 raise ToolingException(f"Path '{rootDirectory}' doesn't exist.") from FileNotFoundError(rootDirectory)
1332 self._ids = {}
1333 self._brokenSymbolicLinks = []
1334 self._unconnectedSymbolicLinks = []
1336 super().__init__(rootDirectory.name)
1337 self._root = self
1338 self._path = rootDirectory
1340 if collectSubdirectories:
1341 self.CollectSubdirectories()
1342 self.ResolveSymbolicLinks()
1344 @readonly
1345 def Path(self) -> Path:
1346 """
1347 Read-only property to access the path of the filesystem statistics root.
1349 :returns: Path to the root of the filesystem statistics root directory.
1350 """
1351 return self._path
1353 @readonly
1354 def BrokenSymbolicLinks(self) -> list[SymbolicLink]:
1355 """
1356 Read-only property to access all symbolic links with a non-existing target (:attr:`_brokenSymbolicLinks`).
1358 :returns: List of broken symbolic links.
1359 """
1360 return self._brokenSymbolicLinks
1362 @readonly
1363 def UnconnectedSymbolicLinks(self) -> list[SymbolicLink]:
1364 """
1365 Read-only property to access all symbolic links that couldn't be resolved within the scanned filesystem (:attr:`_unconnectedSymbolicLinks`).
1367 :returns: List of unconnected symbolic links.
1368 """
1369 return self._unconnectedSymbolicLinks
1371 @readonly
1372 def TotalHardLinkCount(self) -> int:
1373 """
1374 Read-only property to return the accumulated number of hardlinks to multiply-linked files.
1376 Every file storage object referenced by more than one directory entry contributes its number of
1377 directory entries.
1379 :returns: Sum of directory entries over all hardlinked files.
1380 """
1381 return sum(l for f in self._ids.values() if (l := len(f._parents)) > 1)
1383 @readonly
1384 def TotalHardLinkCount2(self) -> int:
1385 """
1386 Read-only property to return the number of file storage objects that are hardlinked.
1388 In contrast to :attr:`TotalHardLinkCount`, every hardlinked file contributes ``1``, regardless of how
1389 many directory entries reference it.
1391 :returns: Number of files referenced by more than one directory entry.
1392 """
1393 return sum(1 for f in self._ids.values() if len(f._parents) > 1)
1395 @readonly
1396 def TotalHardLinkCount3(self) -> int:
1397 """
1398 Read-only property to return the number of file storage objects that are **not** hardlinked.
1400 .. attention::
1402 Despite the name, this counts files referenced by exactly *one* directory entry.
1404 :returns: Number of files referenced by exactly one directory entry.
1405 """
1406 return sum(1 for f in self._ids.values() if len(f._parents) == 1)
1408 @readonly
1409 def Size2(self) -> int:
1410 """
1411 Read-only property to return the accumulated size of all hardlinked files, counted once each.
1413 :returns: Sum of sizes over all files referenced by more than one directory entry.
1414 """
1415 return sum(f._size for f in self._ids.values() if len(f._parents) > 1)
1417 @readonly
1418 def Size3(self) -> int:
1419 """
1420 Read-only property to return the accumulated size of all hardlinked files, counted per directory entry.
1422 In contrast to :attr:`Size2`, a file's size is multiplied by the number of directory entries
1423 referencing it, so it reflects the size a filesystem without hardlink support would need.
1425 :returns: Sum of sizes over all hardlinked files, weighted by their number of directory entries.
1426 """
1427 return sum(f._size * len(f._parents) for f in self._ids.values() if len(f._parents) > 1)
1429 @readonly
1430 def TotalUniqueFileCount(self) -> int:
1431 """
1432 Read-only property to return the number of distinct file storage objects, counting hardlinks to the same content once.
1434 :returns: Number of unique files.
1435 """
1436 return len(self._ids)
1438 def RegisterBrokenSymbolicLink(self, symLink: SymbolicLink) -> None:
1439 """
1440 Mark a symbolic link as broken and collect it at the root.
1442 :param symLink: The symbolic link whose target doesn't exist.
1443 """
1444 symLink._isBroken = True
1445 self._brokenSymbolicLinks.append(symLink)
1447 def RegisterUnconnectedSymbolicLink(self, symLink: SymbolicLink) -> None:
1448 """
1449 Mark a symbolic link as out of range and collect it at the root.
1451 :param symLink: The symbolic link whose target lies outside the scanned tree.
1452 """
1453 symLink._isOutOfRange = True
1454 self._unconnectedSymbolicLinks.append(symLink)
1456 def Copy(self) -> Root:
1457 """
1458 Copy the directory structure including all subelements and link it to the given parent.
1460 The duration for the deep copy process is provided in :attr:`ScanDuration`
1462 .. hint::
1464 Statistics like aggregated directory size are copied too. |br|
1465 There is no rescan or repeated aggregation needed.
1467 :returns: A deep copy of the directory structure.
1468 """
1469 with Stopwatch() as sw:
1470 root = Root(self._path, False)
1471 root._size = self._size
1473 for subdir in self._subdirectories.values():
1474 subdir.Copy(root)
1476 for file in self._files.values():
1477 file.Copy(root)
1479 for link in self._symbolicLinks.values():
1480 link.Copy(root)
1482 root._scanDuration = sw.Duration
1483 root._aggregateDuration = 0.0
1485 return root
1487 def __repr__(self) -> str:
1488 """
1489 Return a detailed string representation of this filesystem root.
1491 :returns: The root's path and the number of directories, regular files and symbolic links below it.
1492 """
1493 return f"Root: {self.Path} (dirs: {self.TotalSubdirectoryCount}, files: {self.TotalRegularFileCount}, symlinks: {self.TotalSymbolicLinkCount})"
1495 def __str__(self) -> str:
1496 """
1497 Return a string representation of this filesystem element.
1499 :returns: The element's name, without any path.
1500 """
1501 return self._name
1504@export
1505class File(Base):
1506 """
1507 A **File** represents a file storage object in the filesystem, which is accessible by one or more :class:`Filename` objects.
1509 Each file has an internal id, which is associated to a unique ID within the host's filesystem.
1510 """
1511 _id: int #: Unique (host internal) file object ID)
1512 _parents: list[Filename] #: List of reverse references to :class:`Filename` objects.
1514 def __init__(
1515 self,
1516 id: int,
1517 size: int,
1518 parent: Nullable[Filename] = None
1519 ) -> None:
1520 """
1521 Initialize the File storage object with an ID, size and parent reference.
1523 :param id: Unique ID of the file object.
1524 :param size: Optional, size of the file object.
1525 :param parent: Optional, parent reference.
1526 :raises ValueError: If parameter 'id' is None.
1527 :raises TypeError: If parameter 'parent' is not of type :class:`Filename`.
1528 """
1529 if id is None: 1529 ↛ 1530line 1529 didn't jump to line 1530 because the condition on line 1529 was never true
1530 raise ValueError("Parameter 'id' is None.")
1531 elif not isinstance(id, int): 1531 ↛ 1532line 1531 didn't jump to line 1532 because the condition on line 1531 was never true
1532 ex = TypeError("Parameter 'id' is not of type 'int'.")
1533 ex.add_note(f"Got type '{getFullyQualifiedName(id)}'.")
1534 raise ex
1536 self._id = id
1538 if parent is None:
1539 super().__init__(size, None)
1540 self._parents = []
1541 elif isinstance(parent, Filename): 1541 ↛ 1546line 1541 didn't jump to line 1546 because the condition on line 1541 was always true
1542 super().__init__(size, parent._root)
1543 self._parents = [parent]
1544 parent._file = self
1545 else:
1546 ex = TypeError("Parameter 'parent' is not of type 'Filename'.")
1547 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
1548 raise ex
1550 @readonly
1551 def ID(self) -> int:
1552 """
1553 Read-only property to access the file object's unique identifier.
1555 :returns: Unique file object identifier.
1556 """
1557 return self._id
1559 @readonly
1560 def Parents(self) -> list[Filename]:
1561 """
1562 Read-only property to access the list of filenames using the same file storage object.
1564 .. hint::
1566 This allows to check if a file object has multiple filenames a.k.a hardlinks.
1568 :returns: List of filenames for the file storage object.
1569 """
1570 return self._parents
1572 def AddParent(self, filename: Filename) -> None:
1573 """
1574 Add another parent reference to a :class:`Filename`.
1576 :param filename: Reference to a filename object.
1577 :raises ValueError: If parameter 'filename' is None.
1578 :raises TypeError: If parameter 'filename' is not of type :class:`Filename`.
1579 :raises ToolingException: If the filename already references another file object.
1580 """
1581 if filename is None: 1581 ↛ 1582line 1581 didn't jump to line 1582 because the condition on line 1581 was never true
1582 raise ValueError("Parameter 'filename' is None.")
1583 elif not isinstance(filename, Filename): 1583 ↛ 1584line 1583 didn't jump to line 1584 because the condition on line 1583 was never true
1584 ex = TypeError("Parameter 'filename' is not of type 'Filename'.")
1585 ex.add_note(f"Got type '{getFullyQualifiedName(filename)}'.")
1586 raise ex
1587 elif filename._file is not None: 1587 ↛ 1588line 1587 didn't jump to line 1588 because the condition on line 1587 was never true
1588 raise ToolingException(f"Filename is already referencing an other file object ({filename._file._id}).")
1590 self._parents.append(filename)
1591 filename._file = self
1593 if filename._root is not None:
1594 self._root = filename._root