Coverage for pyTooling/Filesystem/__init__.py: 59%
615 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 13:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 13:02 +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 os import scandir, readlink
51from pathlib import Path
52from typing import Optional as Nullable, Generic, Generator, TypeVar, Any, Callable, Union
53from typing import Iterator, cast
55from pyTooling.Decorators import readonly, export
56from pyTooling.Exceptions import ToolingException
57from pyTooling.MetaClasses import ExtendedType, abstractmethod
58from pyTooling.Common import getFullyQualifiedName, zipdicts
59from pyTooling.Warning import WarningCollector, Warning
60from pyTooling.Stopwatch import Stopwatch
61from pyTooling.Tree import Node
64__all__ = ["_ParentType"]
67_ParentType = TypeVar("_ParentType", bound="Element")
68"""The type variable for a parent reference."""
71@export
72class FilesystemError(ToolingException):
73 """Base-exception of all exceptions raised by :mod:`pyTooling.Filesystem`."""
76@export
77class PermissionWarning(Warning):
78 """
79 Warning emitted when a directory or file couldn't be read while scanning a filesystem.
81 The scan continues, so the collected statistics are incomplete by exactly the path this warning carries.
83 .. attention::
85 It's a non-critical warning: without a :class:`~pyTooling.Warning.WarningCollector` up the call-hierarchy, it's
86 dropped silently.
87 """
88 _path: Path #: Path that couldn't be read.
90 def __init__(self, path: Path, *args: Any) -> None:
91 """
92 Initialize a permission warning for the path that couldn't be read.
94 :param path: The path that raised a :exc:`PermissionError`.
95 :param args: Positional parameters forwarded to the base-class.
96 """
97 super().__init__(*args)
98 self._path = path
100 @readonly
101 def Path(self) -> Path:
102 """
103 Read-only property to access the path that couldn't be read (:attr:`_path`).
105 :returns: The path that raised a :exc:`PermissionError`.
106 """
107 return self._path
110@export
111class NodeKind(Enum):
112 """
113 Node kind for filesystem elements in a :ref:`tree <STRUCT/Tree>`.
115 This enumeration is used when converting the filesystem statistics tree to an instance of :mod:`pyTooling.Tree`.
116 """
117 Directory = 0 #: Node represents a directory.
118 File = 1 #: Node represents a regular file.
119 SymbolicLink = 2 #: Node represents a symbolic link.
122@export
123class Base(metaclass=ExtendedType, slots=True):
124 """
125 Base-class for all filesystem elements in :mod:`pyTooling.Filesystem`.
127 It implements a size and a reference to the root element of the filesystem.
128 """
129 _root: Nullable[Root] #: Reference to the root of the filesystem statistics scope.
130 _size: Nullable[int] #: Actual or aggregated size of the filesystem element.
132 def __init__(
133 self,
134 size: Nullable[int],
135 root: Nullable[Root]
136 ) -> None:
137 """
138 Initialize the base-class with filesystem element size and root reference.
140 :param size: Size of the element.
141 :param root: Reference to the filesystem root element.
142 :raises TypeError: If parameter 'size' is not of type integer.
143 :raises TypeError: If parameter 'root' is not of type :class:`Root`.
144 """
145 if size is not None and not isinstance(size, int): 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true
146 ex = TypeError("Parameter 'size' is not of type 'int'.")
147 ex.add_note(f"Got type '{getFullyQualifiedName(size)}'.")
148 raise ex
150 if root is not None and not isinstance(root, Root): 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true
151 ex = TypeError("Parameter 'root' is not of type 'Root'.")
152 ex.add_note(f"Got type '{getFullyQualifiedName(root)}'.")
153 raise ex
155 self._size = size
156 self._root = root
158 @property
159 def Root(self) -> Nullable[Root]:
160 """
161 Property to access the root of the filesystem statistics scope.
163 :returns: Root of the filesystem statistics scope.
164 :raises ValueError: If ``None`` is assigned.
165 :raises TypeError: If an assigned value is not of type :class:`Root`.
166 """
167 return self._root
169 @Root.setter
170 def Root(self, value: Root) -> None:
171 if value is None: 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 raise ValueError("Parameter 'value' is None.")
173 elif not isinstance(value, Root): 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true
174 ex = TypeError("Parameter 'value' is not of type 'Root'.")
175 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
176 raise ex
178 self._root = value
180 @readonly
181 def Size(self) -> int:
182 """
183 Read-only property to access the element's size in Bytes.
185 :returns: Size in Bytes.
186 :raises FilesystemError: If size is not computed, yet.
187 """
188 if self._size is None: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 raise FilesystemError("Size is not computed, yet.")
191 return self._size
193 @abstractmethod
194 def ToTree(self) -> Node: # type: ignore[empty-body]
195 """
196 Convert a filesystem element to a node in :mod:`pyTooling.Tree`.
198 The node's :attr:`~pyTooling.Tree.Node.Value` field contains a reference to the filesystem element. Additional data
199 will be stored in the node's key-value store.
201 :returns: A tree's node referencing this filesystem element.
202 """
205@export
206class Element(Base, Generic[_ParentType]):
207 """
208 Base-class for all named elements within a filesystem.
210 It adds a name, parent reference and list of symbolic-link sources.
212 .. hint::
214 Symbolic link sources are reverse references describing which symbolic links point to this element.
215 """
216 _name: str #: Name of the filesystem element.
217 _parent: _ParentType #: Reference to the filesystem element's parent (:class:`Directory`)
218 _linkSources: list[SymbolicLink] #: A list of symbolic links pointing to this filesystem element.
220 def __init__(
221 self,
222 name: str,
223 size: Nullable[int] = None,
224 parent: Nullable[_ParentType] = None
225 ) -> None:
226 """
227 Initialize the element base-class with name, size and parent reference.
229 :param name: Name of the element.
230 :param size: Optional, size of the element.
231 :param parent: Optional, parent reference.
232 :raises ValueError: If parameter 'name' is None.
233 :raises TypeError: If parameter 'parent' is not of type :class:`Directory`.
234 """
235 if name is None: 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true
236 raise ValueError("Parameter 'name' is None.")
237 elif not isinstance(name, str): 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 ex = TypeError("Parameter 'name' is not of type 'str'.")
239 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
240 raise ex
242 self._name = name
244 if parent is None:
245 super().__init__(size, None)
246 self._parent = None
247 else:
248 if not isinstance(parent, Directory): 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true
249 ex = TypeError("Parameter 'parent' is not of type 'Directory'.")
250 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
251 raise ex
253 super().__init__(size, parent._root)
254 self._parent = parent
256 self._linkSources = []
258 @property
259 def Parent(self) -> _ParentType:
260 """
261 Property to access the element's parent.
263 :returns: Parent element.
264 :raises ValueError: If ``None`` is assigned.
265 :raises TypeError: If an assigned value is not of type :class:`Directory`.
266 """
267 return self._parent
269 @Parent.setter
270 def Parent(self, value: _ParentType) -> None:
271 if value is None: 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 raise ValueError("Parameter 'value' is None.")
273 elif not isinstance(value, Directory): 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true
274 ex = TypeError("Parameter 'value' is not of type 'Directory'.")
275 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
276 raise ex
278 self._parent = value
280 if value._root is not None:
281 self._root = value._root
283 @readonly
284 def Name(self) -> str:
285 """
286 Read-only property to access the element's name.
288 :returns: Element name.
289 """
290 return self._name
292 @readonly
293 @abstractmethod
294 def Path(self) -> Path: # type: ignore[empty-body]
295 """
296 Read-only property to access the element's path.
298 :returns: Path of the element.
299 """
301 @readonly
302 def LinkSources(self) -> list[SymbolicLink]:
303 """
304 Read-only property to access the symbolic links pointing to this element (:attr:`_linkSources`).
306 :returns: List of symbolic links targeting this element.
307 """
308 return self._linkSources
310 def AddLinkSources(self, source: SymbolicLink) -> None:
311 """
312 Add a link source of a symbolic link to the named element (reverse reference).
314 :param source: The referenced symbolic link.
315 :raises TypeError: If parameter 'source' is not of type :class:`SymbolicLink`.
316 """
317 if not isinstance(source, SymbolicLink):
318 ex = TypeError("Parameter 'source' is not of type 'SymbolicLink'.")
319 ex.add_note(f"Got type '{getFullyQualifiedName(source)}'.")
320 raise ex
322 source._isConnected = True
323 source._isBroken = False
324 source._isOutOfRange = False
325 self._linkSources.append(source)
328@export
329class Directory(Element["Directory"]):
330 """
331 A **directory** represents a directory in the filesystem, which contains subdirectories, regular files and symbolic links.
333 While scanning for subelements, the directory is populated with elements. Every file object added, gets registered in
334 the filesystems :class:`Root` for deduplication. In case a file identifier already exists, the found filename will
335 reference the same file objects. In turn, the file objects has then references to multiple filenames (parents). This
336 allows to detect :term:`hardlinks <hardlink>`.
338 The time needed for scanning the directory and its subelements is provided via :data:`ScanDuration`.
340 After scnaning the directory for subelements, certain directory properties get aggregated. The time needed for
341 aggregation is provided via :data:`AggregateDuration`.
342 """
344 _path: Nullable[Path] #: Cached :class:`~pathlib.Path` object of this directory.
345 _subdirectories: dict[str, Directory] #: Dictionary containing name-:class:`Directory` pairs.
346 _files: dict[str, Filename] #: Dictionary containing name-:class:`Filename` pairs.
347 _symbolicLinks: dict[str, SymbolicLink] #: Dictionary containing name-:class:`SymbolicLink` pairs.
348 _filesSize: int #: Aggregated size of all direct files.
349 _collapsed: bool #: True, if this directory was collapsed. It contains no subelements.
350 _scanDuration: Nullable[float] #: Duration for scanning the directory and all its subelements.
351 _aggregateDuration: Nullable[float] #: Duration for aggregating all subelements.
353 def __init__(
354 self,
355 name: str,
356 collectSubdirectories: bool = False,
357 parent: Nullable[Directory] = None
358 ) -> None:
359 """
360 Initialize the directory with name and parent reference.
362 If ``collectSubdirectories`` is ``True``, a directory that can't be read is reported as a :class:`PermissionWarning`
363 to a :class:`~pyTooling.Warning.WarningCollector`; without a collector, it's dropped.
365 :param name: Name of the element.
366 :param collectSubdirectories: Optional, if ``True``, collect subdirectory statistics.
367 :param parent: Optional, parent reference.
368 """
369 super().__init__(name, None, parent)
371 self._path = None
372 self._subdirectories = {}
373 self._files = {}
374 self._symbolicLinks = {}
375 self._filesSize = 0
376 self._collapsed = False
377 self._scanDuration = None
378 self._aggregateDuration = None
380 if parent is not None:
381 parent._subdirectories[name] = self
383 if parent._root is not None: 383 ↛ 386line 383 didn't jump to line 386 because the condition on line 383 was always true
384 self._root = parent._root
386 if collectSubdirectories:
387 self.CollectSubdirectories()
389 def CollectSubdirectories(self) -> None:
390 """
391 Helper method for scanning subdirectories and aggregating found element sizes therein.
393 A directory that can't be read is reported as a :class:`PermissionWarning` to a
394 :class:`~pyTooling.Warning.WarningCollector`; without a collector, it's dropped.
395 """
396 self.ScanSubdirectories()
397 self.AggregateSizes()
399 def ScanSubdirectories(self) -> None:
400 """
401 Helper method for scanning subdirectories (recursively) and building a
402 :class:`Directory`-:class:`Filename`-:class:`File` object tree.
404 If a file refers to the same filesystem internal unique ID, a hardlink (two or more filenames) to the same file
405 storage object is assumed.
407 A directory that can't be read is reported as a :class:`PermissionWarning` to a
408 :class:`~pyTooling.Warning.WarningCollector` and skipped, so the scan continues and the collected statistics are
409 incomplete by exactly that path. Without a collector, the warning is dropped.
411 :raises FilesystemError: If this directory isn't attached to a :class:`Root`, which owns the ID table.
412 :raises FilesystemError: If the directory contains an element that is neither a directory, a file nor a
413 symbolic link.
414 """
415 if (root := self._root) is None:
416 raise FilesystemError(f"Directory '{self._name}' is not attached to a filesystem root.")
418 with Stopwatch() as sw1:
419 try:
420 items = scandir(directoryPath := self.Path)
421 except PermissionError as ex:
422 WarningCollector.Raise(PermissionWarning(self.Path), ex)
423 return
425 for dirEntry in items:
426 if dirEntry.is_dir(follow_symlinks=False):
427 _ = Directory(dirEntry.name, collectSubdirectories=True, parent=self)
428 elif dirEntry.is_file(follow_symlinks=False): 428 ↛ 440line 428 didn't jump to line 440 because the condition on line 428 was always true
429 id = dirEntry.inode()
430 if id in root._ids: 430 ↛ 431line 430 didn't jump to line 431 because the condition on line 430 was never true
431 file = root._ids[id]
433 _ = Filename(dirEntry.name, file=file, parent=self)
434 else:
435 s = dirEntry.stat(follow_symlinks=False)
436 filename = Filename(dirEntry.name, parent=self)
437 file = File(id, s.st_size, parent=filename)
439 root._ids[id] = file
440 elif dirEntry.is_symlink():
441 target = Path(readlink(directoryPath / dirEntry.name))
442 _ = SymbolicLink(dirEntry.name, target, parent=self)
443 else:
444 raise FilesystemError("Unknown directory element.")
446 self._scanDuration = sw1.Duration
448 def ResolveSymbolicLinks(self) -> None:
449 """
450 Resolve the symbolic links of this directory and of every directory below it.
452 A link whose target lies inside the scanned tree is connected to that element; a target that doesn't exist
453 registers the link as broken, and a target outside the scanned tree registers it as unconnected.
454 """
455 for dir in self._subdirectories.values():
456 dir.ResolveSymbolicLinks()
458 for link in self._symbolicLinks.values(): 458 ↛ 459line 458 didn't jump to line 459 because the loop on line 458 never started
459 if link._target.is_absolute():
460 # todo: resolve path and check if target is in range, otherwise add to out-of-range list
461 pass
462 else:
463 target = self
464 for elem in link._target.parts:
465 if elem == ".":
466 continue
467 elif elem == "..":
468 if (target := target._parent) is None:
469 self._root.RegisterUnconnectedSymbolicLink(link)
470 break
472 continue
474 try:
475 target = target._subdirectories[elem]
476 continue
477 except KeyError:
478 pass
480 try:
481 target = target._files[elem]
482 continue
483 except KeyError:
484 pass
486 try:
487 target = target._symbolicLinks[elem]
488 continue
489 except KeyError:
490 self._root.RegisterBrokenSymbolicLink(link)
491 break
492 else:
493 target.AddLinkSources(link)
495 def AggregateSizes(self) -> set[File]:
496 """
497 Compute the aggregated size of this directory and of every directory below it.
499 A file is counted once, even when several filenames (hardlinks) refer to it, which is why the already counted
500 files are returned and handed up the recursion.
502 :returns: The set of file objects counted in this subtree.
503 """
504 with Stopwatch() as sw2:
505 aggregatedFiles = set()
507 self._size = 0
508 self._filesSize = 0
509 for dir in self._subdirectories.values():
510 aggregatedFiles |= dir.AggregateSizes()
511 self._size += dir._size
513 for filename in self._files.values():
514 if (file := filename._file) not in aggregatedFiles: 514 ↛ 513line 514 didn't jump to line 513 because the condition on line 514 was always true
515 self._filesSize += file._size
516 aggregatedFiles.add(file)
518 self._size += self._filesSize
520 self._aggregateDuration = sw2.Duration
522 return aggregatedFiles
524 @Element.Root.setter
525 def Root(self, value: Root) -> None:
526 Element.Root.fset(self, value)
528 for subdir in self._subdirectories.values(): 528 ↛ 529line 528 didn't jump to line 529 because the loop on line 528 never started
529 subdir.Root = value
531 for file in self._files.values():
532 file.Root = value
534 for link in self._symbolicLinks.values(): 534 ↛ 535line 534 didn't jump to line 535 because the loop on line 534 never started
535 link.Root = value
537 @Element.Parent.setter
538 def Parent(self, value: _ParentType) -> None:
539 Element.Parent.fset(self, value)
541 value._subdirectories[self._name] = self
543 if isinstance(value, Root): 543 ↛ exitline 543 didn't return from function 'Parent' because the condition on line 543 was always true
544 self.Root = value
546 @readonly
547 def Count(self) -> int:
548 """
549 Read-only property to return the number of elements in a directory.
551 :returns: Number of files plus subdirectories.
552 """
553 return len(self._subdirectories) + len(self._files) + len(self._symbolicLinks)
555 @readonly
556 def FileCount(self) -> int:
557 """
558 Read-only property to return the number of files in a directory.
560 .. hint::
562 Files include regular files and symbolic links.
564 :returns: Number of files.
565 """
566 return len(self._files) + len(self._symbolicLinks)
568 @readonly
569 def RegularFileCount(self) -> int:
570 """
571 Read-only property to return the number of regular files in a directory.
573 :returns: Number of regular files.
574 """
575 return len(self._files)
577 @readonly
578 def SymbolicLinkCount(self) -> int:
579 """
580 Read-only property to return the number of symbolic links in a directory.
582 :returns: Number of symbolic links.
583 """
584 return len(self._symbolicLinks)
586 @readonly
587 def SubdirectoryCount(self) -> int:
588 """
589 Read-only property to return the number of subdirectories in a directory.
591 :returns: Number of subdirectories.
592 """
593 return len(self._subdirectories)
595 @readonly
596 def TotalFileCount(self) -> int:
597 """
598 Read-only property to return the total number of files in all child hierarchy levels (recursively).
600 .. hint::
602 Files include regular files and symbolic links.
604 :returns: Total number of files.
605 """
606 return sum(d.TotalFileCount for d in self._subdirectories.values()) + len(self._files) + len(self._symbolicLinks)
608 @readonly
609 def TotalRegularFileCount(self) -> int:
610 """
611 Read-only property to return the total number of regular files in all child hierarchy levels (recursively).
613 :returns: Total number of regular files.
614 """
615 return sum(d.TotalRegularFileCount for d in self._subdirectories.values()) + len(self._files)
617 @readonly
618 def TotalSymbolicLinkCount(self) -> int:
619 """
620 Read-only property to return the total number of symbolic links in all child hierarchy levels (recursively).
622 :returns: Total number of symbolic links.
623 """
624 return sum(d.TotalSymbolicLinkCount for d in self._subdirectories.values()) + len(self._symbolicLinks)
626 @readonly
627 def TotalSubdirectoryCount(self) -> int:
628 """
629 Read-only property to return the total number of subdirectories in all child hierarchy levels (recursively).
631 :returns: Total number of subdirectories.
632 """
633 return len(self._subdirectories) + sum(d.TotalSubdirectoryCount for d in self._subdirectories.values())
635 @readonly
636 def Subdirectories(self) -> Generator[Directory, None, None]:
637 """
638 Iterate all direct subdirectories of the directory.
640 :returns: A generator to iterate all direct subdirectories.
641 """
642 return (d for d in self._subdirectories.values())
644 @readonly
645 def Files(self) -> Generator[Filename | SymbolicLink, None, None]:
646 """
647 Iterate all direct files of the directory.
649 .. hint::
651 Files include regular files and symbolic links.
653 :returns: A generator to iterate all direct files.
654 """
655 return (f for f in (*self._files.values(), *self._symbolicLinks.values()))
657 @readonly
658 def RegularFiles(self) -> Generator[Filename, None, None]:
659 """
660 Iterate all direct regular files of the directory.
662 :returns: A generator to iterate all direct regular files.
663 """
664 return (f for f in self._files.values())
666 @readonly
667 def SymbolicLinks(self) -> Generator[SymbolicLink, None, None]:
668 """
669 Iterate all direct symbolic links of the directory.
671 :returns: A generator to iterate all direct symbolic links.
672 """
673 return (l for l in self._symbolicLinks.values())
675 @readonly
676 def Path(self) -> Path:
677 """
678 Read-only property to access the equivalent Path instance for accessing the represented directory.
680 :returns: Path to the directory.
681 :raises FilesystemError: If no parent is set.
682 """
683 if self._path is not None:
684 return self._path
686 if self._parent is None: 686 ↛ 687line 686 didn't jump to line 687 because the condition on line 686 was never true
687 raise FilesystemError("No parent or root set for directory.")
689 self._path = self._parent.Path / self._name
690 return self._path
692 @readonly
693 def ScanDuration(self) -> float:
694 """
695 Read-only property to access the time needed to scan a directory structure including all subelements (recursively).
697 :returns: The scan duration in seconds.
698 :raises FilesystemError: If the directory was not scanned.
699 """
700 if self._scanDuration is None:
701 raise FilesystemError("Directory was not scanned, yet.")
703 return self._scanDuration
705 @readonly
706 def AggregateDuration(self) -> float:
707 """
708 Read-only property to access the time needed to aggregate the directory's and subelement's properties (recursively).
710 :returns: The aggregation duration in seconds.
711 :raises FilesystemError: If the directory properties were not aggregated.
712 """
713 if self._scanDuration is None:
714 raise FilesystemError("Directory properties were not aggregated, yet.")
716 return self._aggregateDuration
718 def __hash__(self) -> int:
719 """
720 Compute a hash for this filesystem element based on its identity.
722 Two elements with the same name in different directories are different elements, so the hash is derived from the
723 object's identity and not from its name.
725 :returns: Hash of this filesystem element.
726 """
727 return hash(id(self))
729 def IterateDirectories(self) -> Generator[Directory, None, None]:
730 """
731 A generator to iterate all subdirectories below this directory in pre-order.
733 A parent directory is yielded before its children.
735 :returns: A generator to iterate all subdirectories below this directory.
736 """
737 # pre-order
738 for directory in self._subdirectories.values():
739 yield directory
740 yield from directory.IterateDirectories()
742 def IterateFiles(self) -> Generator[Element, None, None]:
743 """
744 A generator to iterate all files and symbolic links below this directory in post-order.
746 The elements of the subdirectories are yielded before this directory's own.
748 :returns: A generator to iterate all files and symbolic links below this directory.
749 """
750 # post-order
751 for directory in self._subdirectories.values():
752 yield from directory.IterateFiles()
754 yield from self._files.values()
755 yield from self._symbolicLinks.values()
757 def Copy(self, parent: Nullable[Directory] = None) -> Directory:
758 """
759 Copy the directory structure including all subelements and link it to the given parent.
761 .. hint::
763 Statistics like aggregated directory size are copied too. |br|
764 There is no rescan or repeated aggregation needed.
766 :param parent: Optional, the parent element of the copied directory.
767 :returns: A deep copy of the directory structure.
768 """
769 dir = Directory(self._name, parent=parent)
770 dir._size = self._size
772 for subdir in self._subdirectories.values():
773 subdir.Copy(dir)
775 for file in self._files.values():
776 file.Copy(dir)
778 for link in self._symbolicLinks.values():
779 link.Copy(dir)
781 return dir
783 def Collapse(self, func: Callable[[Directory], bool]) -> bool:
784 """
785 Collapse this directory's subtree where the given predicate accepts it.
787 A directory is collapsed when it has no subdirectories left - or all of them collapsed - and the predicate
788 accepts it. Collapsing discards the directory's elements, so only its aggregated numbers remain.
790 :param func: Predicate deciding whether a directory may be collapsed.
791 :returns: ``True``, if this directory was collapsed.
792 """
793 # if len(self._subdirectories) == 0 or all(subdir.Collapse(func) for subdir in self._subdirectories.values()):
794 if len(self._subdirectories) == 0:
795 if func(self):
796 # print(f"collapse 1 {self.Path}")
797 self._collapsed = True
798 self._subdirectories.clear()
799 self._files.clear()
800 self._symbolicLinks.clear()
802 return True
803 else:
804 return False
806 # if all(subdir.Collapse(func) for subdir in self._subdirectories.values())
807 collapsible = True
808 for subdir in self._subdirectories.values():
809 result = subdir.Collapse(func)
810 collapsible = collapsible and result
812 if collapsible:
813 # print(f"collapse 2 {self.Path}")
814 self._collapsed = True
815 self._subdirectories.clear()
816 self._files.clear()
817 self._symbolicLinks.clear()
819 return True
820 else:
821 return False
823 def ToTree(self, format: Nullable[Callable[[Node], str]] = None) -> Node:
824 """
825 Convert the directory to a :class:`~pyTooling.Tree.Node`.
827 The node's :attr:`~pyTooling.Tree.Node.Value` field contains a reference to the directory. Additional data is
828 attached to the node's key-value store:
830 ``kind``
831 The node's kind: :attr:`NodeKind.Directory`.
832 ``size``
833 The directory's aggregated size.
835 Its subdirectories, files and symbolic links become the node's children, in this order.
837 :param format: Optional, a user defined formatting function for the tree nodes of this directory and its elements.
838 :returns: A tree node representing this directory.
839 """
840 if format is None: 840 ↛ 841line 840 didn't jump to line 841 because the condition on line 840 was never true
841 def format(node: Node) -> str:
842 """
843 Nested function rendering a tree node as one line.
845 :param node: The tree node to render.
846 :returns: The node's size in MiB, followed by its name.
847 """
848 element = cast(Element[Any], node._value) # the node was created with this element as its value
849 return f"{node['size'] * 1e-6:7.1f} MiB {element.Name}"
851 directoryNode: Node[Any, Any, Any, Any] = Node(
852 value=self,
853 keyValuePairs={
854 "kind": NodeKind.Directory,
855 "size": self._size
856 },
857 format=format
858 )
859 directoryNode.AddChildren(
860 e.ToTree(format) for e in (*self._subdirectories.values(), *self._files.values(), *self._symbolicLinks.values())
861 )
863 return directoryNode
865 def __eq__(self, other: Any) -> bool:
866 """
867 Compare two Directory instances for equality.
869 :param other: Parameter to compare against.
870 :returns: ``True``, if both directories and all its subelements are equal.
871 :raises TypeError: If parameter ``other`` is not of type :class:`Directory`.
872 """
873 if not isinstance(other, Directory):
874 ex = TypeError("Parameter 'other' is not of type Directory.")
875 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
876 raise ex
878 if not all(dir1 == dir2 for _, dir1, dir2 in zipdicts(self._subdirectories, other._subdirectories)):
879 return False
881 if not all(file1 == file2 for _, file1, file2 in zipdicts(self._files, other._files)):
882 return False
884 if not all(link1 == link2 for _, link1, link2 in zipdicts(self._symbolicLinks, other._symbolicLinks)):
885 return False
887 return True
889 def __ne__(self, other: Any) -> bool:
890 """
891 Compare two Directory instances for inequality.
893 :param other: Parameter to compare against.
894 :returns: ``True``, if both directories and all its subelements are unequal.
895 :raises TypeError: If parameter ``other`` is not of type :class:`Directory`.
896 """
897 return not self.__eq__(other)
899 def __repr__(self) -> str:
900 """
901 Return a detailed string representation of this directory.
903 :returns: The directory's full path, prefixed by its kind.
904 """
905 return f"Directory: {self.Path}"
907 def __str__(self) -> str:
908 """
909 Return a string representation of this filesystem element.
911 :returns: The element's name, without any path.
912 """
913 return self._name
916@export
917class Filename(Element[Directory]):
918 """
919 Represents a filename in the filesystem, but not the file storage object (:class:`File`).
921 .. hint::
923 Filename and file storage are represented by two classes, which allows multiple names (hard links) per file storage
924 object.
925 """
926 _file: Nullable[File] #: The file this filename refers to; ``None`` until the filename is linked.
928 def __init__(
929 self,
930 name: str,
931 file: Nullable[File] = None,
932 parent: Nullable[Directory] = None
933 ) -> None:
934 """
935 Initialize the filename with name, file (storage) object and parent reference.
937 :param name: Name of the file.
938 :param file: Optional, file (storage) object.
939 :param parent: Optional, parent reference.
940 :raises TypeError: If parameter 'file' is not of type :class:`File`.
941 """
942 super().__init__(name, None, parent)
944 if file is None: 944 ↛ 947line 944 didn't jump to line 947 because the condition on line 944 was always true
945 self._file = None
946 else:
947 if not isinstance(file, File):
948 ex = TypeError("Parameter 'file' is not of type 'File'.")
949 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.")
950 raise ex
952 self._file = file
953 file._parents.append(self)
955 if parent is not None:
956 parent._files[name] = self
958 if parent._root is not None:
959 self._root = parent._root
961 @Element.Root.setter
962 def Root(self, value: Root) -> None:
963 Element.Root.fset(self, value)
965 if self._file is not None: 965 ↛ exitline 965 didn't return from function 'Root' because the condition on line 965 was always true
966 self._file.Root = value
968 @Element.Parent.setter
969 def Parent(self, value: _ParentType) -> None:
970 Element.Parent.fset(self, value)
972 value._files[self._name] = self
974 if isinstance(value, Root): 974 ↛ 975line 974 didn't jump to line 975 because the condition on line 974 was never true
975 self.Root = value
977 @readonly
978 def File(self) -> Nullable[File]:
979 """
980 Read-only property to access the file this filename is linked to (:attr:`_file`).
982 :returns: The linked file, or ``None`` if the filename isn't linked yet.
983 """
984 return self._file
986 @readonly
987 def Size(self) -> int:
988 """
989 Read-only property to access the size of the linked file.
991 :returns: Size of the linked file in bytes.
992 :raises ToolingException: If the filename isn't linked to a file object.
993 """
994 if self._file is None:
995 raise ToolingException("Filename isn't linked to a File object.")
997 return self._file._size
999 @readonly
1000 def Path(self) -> Path:
1001 """
1002 Read-only property to return the filename's absolute path.
1004 The path is computed from the parent directory's path and the filename.
1006 :returns: Absolute path of the file.
1007 :raises ToolingException: If the filename has no parent object.
1008 """
1009 if self._parent is None: 1009 ↛ 1010line 1009 didn't jump to line 1010 because the condition on line 1009 was never true
1010 raise ToolingException("Filename has no parent object.")
1012 return self._parent.Path / self._name
1014 def __hash__(self) -> int:
1015 """
1016 Compute a hash for this filesystem element based on its identity.
1018 Two elements with the same name in different directories are different elements, so the hash is derived from the
1019 object's identity and not from its name.
1021 :returns: Hash of this filesystem element.
1022 """
1023 return hash(id(self))
1025 def Copy(self, parent: Directory) -> Filename:
1026 """
1027 Copy this filename into another filesystem statistics scope.
1029 The file object behind the filename is copied only once per scope: a filename referring to a file that was
1030 already copied - a hardlink - is connected to the existing copy.
1032 :param parent: The directory in the target scope the copy is registered at.
1033 :returns: The copied filename.
1034 """
1035 fileID = self._file._id
1037 if fileID in parent._root._ids:
1038 file = parent._root._ids[fileID]
1039 else:
1040 fileSize = self._file._size
1041 file = File(fileID, fileSize)
1043 parent._root._ids[fileID] = file
1045 return Filename(self._name, file, parent=parent)
1047 def ToTree(self, format: Nullable[Callable[[Node], str]] = None) -> Node:
1048 """
1049 Convert this filename to a node of a :mod:`pyTooling.Tree`.
1051 :param format: Optional, a user defined formatting function for the tree node.
1052 :returns: A tree node carrying this filename, its kind and its size.
1053 """
1054 if format is None: 1054 ↛ 1055line 1054 didn't jump to line 1055 because the condition on line 1054 was never true
1055 def format(node: Node) -> str:
1056 """
1057 Nested function rendering a tree node as one line.
1059 :param node: The tree node to render.
1060 :returns: The node's size in MiB, followed by its name.
1061 """
1062 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
1064 fileNode: Node[Any, Any, Any, Any] = Node(
1065 value=self,
1066 keyValuePairs={
1067 "kind": NodeKind.File,
1068 "size": self._size
1069 },
1070 format=format
1071 )
1073 return fileNode
1075 def __eq__(self, other: Any) -> bool:
1076 """
1077 Compare two Filename instances for equality.
1079 :param other: Parameter to compare against.
1080 :returns: ``True``, if both filenames are equal.
1081 :raises TypeError: If parameter ``other`` is not of type :class:`Filename`.
1082 """
1083 if not isinstance(other, Filename):
1084 ex = TypeError("Parameter 'other' is not of type 'Filename'.")
1085 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
1086 raise ex
1088 return self._name == other._name and self.Size == other.Size
1090 def __ne__(self, other: Any) -> bool:
1091 """
1092 Compare two Filename instances for inequality.
1094 :param other: Parameter to compare against.
1095 :returns: ``True``, if both filenames are unequal.
1096 :raises TypeError: If parameter ``other`` is not of type :class:`Filename`.
1097 """
1098 if not isinstance(other, Filename):
1099 ex = TypeError("Parameter 'other' is not of type 'Filename'.")
1100 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
1101 raise ex
1103 return self._name != other._name or self.Size != other.Size
1105 def __repr__(self) -> str:
1106 """
1107 Return a detailed string representation of this filename.
1109 :returns: The file's full path, prefixed by its kind.
1110 """
1111 return f"File: {self.Path}"
1113 def __str__(self) -> str:
1114 """
1115 Return a string representation of this filesystem element.
1117 :returns: The element's name, without any path.
1118 """
1119 return self._name
1122@export
1123class SymbolicLink(Element[Directory]):
1124 """
1125 A symbolic link in the filesystem statistics scope.
1127 After the scan, the link is resolved: it is either connected to an element of the scanned tree, broken (the target
1128 doesn't exist), or out of range (the target lies outside the scanned tree).
1129 """
1130 _target: Path #: Path the symbolic link points to.
1131 _isConnected: bool #: ``True``, if the link target was resolved to an element of the scanned tree.
1132 _isBroken: Nullable[bool] #: ``True``, if the link target doesn't exist; ``None`` until resolved.
1133 _isOutOfRange: Nullable[bool] #: ``True``, if the link target lies outside the scanned tree; ``None`` until resolved.
1135 def __init__(
1136 self,
1137 name: str,
1138 target: Path,
1139 parent: Nullable[Directory]
1140 ) -> None:
1141 """
1142 Initialize a symbolic link, which is registered at its parent directory.
1144 The link is unresolved at first: :meth:`Root.ResolveSymbolicLinks` decides afterwards whether it is connected,
1145 broken or out of range.
1147 :param name: Name of the symbolic link.
1148 :param target: Path the symbolic link points to.
1149 :param parent: Parent directory of the symbolic link.
1150 :raises ValueError: If parameter 'target' is None.
1151 :raises TypeError: If parameter 'target' is not of type :class:`~pathlib.Path`.
1152 """
1153 super().__init__(name, None, parent)
1155 if target is None: 1155 ↛ 1156line 1155 didn't jump to line 1156 because the condition on line 1155 was never true
1156 raise ValueError("Parameter 'target' is None.")
1157 elif not isinstance(target, Path): 1157 ↛ 1158line 1157 didn't jump to line 1158 because the condition on line 1157 was never true
1158 ex = TypeError("Parameter 'target' is not of type 'Path'.")
1159 ex.add_note(f"Got type '{getFullyQualifiedName(target)}'.")
1160 raise ex
1162 self._target = target
1163 self._isConnected = False
1164 self._isBroken = None
1165 self._isOutOfRange = None
1167 if parent is not None: 1167 ↛ exitline 1167 didn't return from function '__init__' because the condition on line 1167 was always true
1168 parent._symbolicLinks[name] = self
1170 if parent._root is not None: 1170 ↛ exitline 1170 didn't return from function '__init__' because the condition on line 1170 was always true
1171 self._root = parent._root
1173 @readonly
1174 def Path(self) -> Path:
1175 """
1176 Read-only property to return the symbolic link's path.
1178 The path is computed from the parent directory's path and the link's name.
1180 :returns: Path of the symbolic link.
1181 """
1182 return self._parent.Path / self._name
1184 @readonly
1185 def Target(self) -> Path:
1186 """
1187 Read-only property to access the path this symbolic link points to (:attr:`_target`).
1189 :returns: Target path of the symbolic link.
1190 """
1191 return self._target
1193 @readonly
1194 def IsConnected(self) -> bool:
1195 """
1196 Check if the symbolic link was resolved to an element within the scanned filesystem (:attr:`_isConnected`).
1198 :returns: ``True``, if the link's target was found and connected.
1199 """
1200 return self._isConnected
1202 @readonly
1203 def IsBroken(self) -> Nullable[bool]:
1204 """
1205 Check if the symbolic link points to a non-existing target (:attr:`_isBroken`).
1207 :returns: ``True``, if the target doesn't exist. ``None``, if the link wasn't resolved yet.
1208 """
1209 return self._isBroken
1211 @readonly
1212 def IsOutOfRange(self) -> Nullable[bool]:
1213 """
1214 Check if the symbolic link points outside the scanned filesystem (:attr:`_isOutOfRange`).
1216 :returns: ``True``, if the target lies outside the scanned root. ``None``, if the link wasn't resolved yet.
1217 """
1218 return self._isOutOfRange
1220 def __hash__(self) -> int:
1221 """
1222 Compute a hash for this filesystem element based on its identity.
1224 Two elements with the same name in different directories are different elements, so the hash is derived from the
1225 object's identity and not from its name.
1227 :returns: Hash of this filesystem element.
1228 """
1229 return hash(id(self))
1231 def Copy(self, parent: Directory) -> SymbolicLink:
1232 """
1233 Copy this symbolic link into another filesystem statistics scope.
1235 :param parent: The directory in the target scope the copy is registered at.
1236 :returns: The copied symbolic link, unresolved.
1237 """
1238 return SymbolicLink(self._name, self._target, parent=parent)
1240 def ToTree(self, format: Nullable[Callable[[Node], str]] = None) -> Node:
1241 """
1242 Convert this symbolic link to a node of a :mod:`pyTooling.Tree`.
1244 :param format: Optional, a user defined formatting function for the tree node.
1245 :returns: A tree node carrying this symbolic link, its kind and its size.
1246 """
1247 if format is None: 1247 ↛ 1248line 1247 didn't jump to line 1248 because the condition on line 1247 was never true
1248 def format(node: Node) -> str:
1249 """
1250 Nested function rendering a tree node as one line.
1252 :param node: The tree node to render.
1253 :returns: The node's size in MiB, followed by its name.
1254 """
1255 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}"
1257 symbolicLinkNode: Node[Any, Any, Any, Any] = Node(
1258 value=self,
1259 keyValuePairs={
1260 "kind": NodeKind.SymbolicLink,
1261 "size": self._size
1262 },
1263 format=format
1264 )
1266 return symbolicLinkNode
1268 def __eq__(self, other: Any) -> bool:
1269 """
1270 Compare two SymbolicLink instances for equality.
1272 :param other: Parameter to compare against.
1273 :returns: ``True``, if both symbolic links are equal.
1274 :raises TypeError: If parameter ``other`` is not of type :class:`SymbolicLink`.
1275 """
1276 if not isinstance(other, SymbolicLink):
1277 ex = TypeError("Parameter 'other' is not of type 'SymbolicLink'.")
1278 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
1279 raise ex
1281 return self._name == other._name and self._target == other._target
1283 def __ne__(self, other: Any) -> bool:
1284 """
1285 Compare two SymbolicLink instances for inequality.
1287 :param other: Parameter to compare against.
1288 :returns: ``True``, if both symbolic links are unequal.
1289 :raises TypeError: If parameter ``other`` is not of type :class:`SymbolicLink`.
1290 """
1291 if not isinstance(other, SymbolicLink):
1292 ex = TypeError("Parameter 'other' is not of type 'SymbolicLink'.")
1293 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
1294 raise ex
1296 return self._name != other._name or self._target != other._target
1298 def __repr__(self) -> str:
1299 """
1300 Return a detailed string representation of this symbolic link.
1302 :returns: The link's full path and the path it points to.
1303 """
1304 return f"SymLink: {self.Path} -> {self._target}"
1306 def __str__(self) -> str:
1307 """
1308 Return a string representation of this filesystem element.
1310 :returns: The element's name, without any path.
1311 """
1312 return self._name
1315@export
1316class Root(Directory):
1317 """
1318 A **Root** represents the root-directory in the filesystem, which contains subdirectories, regular files and symbolic links.
1319 """
1320 _ids: dict[int, File] #: Dictionary of file identifier - file objects pairs found while scanning the directory structure.
1321 _brokenSymbolicLinks: list[SymbolicLink] #: Broken symbolic links (target doesn't exist).
1322 _unconnectedSymbolicLinks: list[SymbolicLink] #: Symbolic links which couldn't be connected to their target (out of scope).
1324 def __init__(
1325 self,
1326 rootDirectory: Path,
1327 collectSubdirectories: bool = True
1328 ) -> None:
1329 """
1330 Initialize a filesystem statistics scope for the given directory.
1332 Unless ``collectSubdirectories`` is disabled, the whole tree is scanned and its symbolic links are resolved right
1333 away, so the root is usable as soon as it exists. A directory that can't be read is reported as a
1334 :class:`PermissionWarning` to a :class:`~pyTooling.Warning.WarningCollector`; without a collector, it's dropped.
1336 :param rootDirectory: Directory to collect the statistics for.
1337 :param collectSubdirectories: Optional, if ``True``, scan the tree and resolve its symbolic links immediately.
1338 :raises ValueError: If parameter 'rootDirectory' is None.
1339 :raises TypeError: If parameter 'rootDirectory' is not of type :class:`~pathlib.Path`.
1340 :raises ToolingException: If the given path doesn't exist.
1341 """
1342 if rootDirectory is None: 1342 ↛ 1343line 1342 didn't jump to line 1343 because the condition on line 1342 was never true
1343 raise ValueError("Parameter 'rootDirectory' is None.")
1344 elif not isinstance(rootDirectory, Path): 1344 ↛ 1345line 1344 didn't jump to line 1345 because the condition on line 1344 was never true
1345 raise TypeError("Parameter 'rootDirectory' is not of type 'Path'.")
1346 elif not rootDirectory.exists(): 1346 ↛ 1347line 1346 didn't jump to line 1347 because the condition on line 1346 was never true
1347 raise ToolingException(f"Path '{rootDirectory}' doesn't exist.") from FileNotFoundError(rootDirectory)
1349 self._ids = {}
1350 self._brokenSymbolicLinks = []
1351 self._unconnectedSymbolicLinks = []
1353 super().__init__(rootDirectory.name)
1354 self._root = self
1355 self._path = rootDirectory
1357 if collectSubdirectories:
1358 self.CollectSubdirectories()
1359 self.ResolveSymbolicLinks()
1361 @readonly
1362 def Path(self) -> Path:
1363 """
1364 Read-only property to access the path of the filesystem statistics root.
1366 :returns: Path to the root of the filesystem statistics root directory.
1367 """
1368 return self._path
1370 @readonly
1371 def BrokenSymbolicLinks(self) -> list[SymbolicLink]:
1372 """
1373 Read-only property to access all symbolic links with a non-existing target (:attr:`_brokenSymbolicLinks`).
1375 :returns: List of broken symbolic links.
1376 """
1377 return self._brokenSymbolicLinks
1379 @readonly
1380 def UnconnectedSymbolicLinks(self) -> list[SymbolicLink]:
1381 """
1382 Read-only property to access all symbolic links that couldn't be resolved within the scanned filesystem (:attr:`_unconnectedSymbolicLinks`).
1384 :returns: List of unconnected symbolic links.
1385 """
1386 return self._unconnectedSymbolicLinks
1388 @readonly
1389 def TotalHardLinkCount(self) -> int:
1390 """
1391 Read-only property to return the accumulated number of hardlinks to multiply-linked files.
1393 Every file storage object referenced by more than one directory entry contributes its number of
1394 directory entries.
1396 :returns: Sum of directory entries over all hardlinked files.
1397 """
1398 return sum(l for f in self._ids.values() if (l := len(f._parents)) > 1)
1400 @readonly
1401 def TotalHardLinkCount2(self) -> int:
1402 """
1403 Read-only property to return the number of file storage objects that are hardlinked.
1405 In contrast to :attr:`TotalHardLinkCount`, every hardlinked file contributes ``1``, regardless of how
1406 many directory entries reference it.
1408 :returns: Number of files referenced by more than one directory entry.
1409 """
1410 return sum(1 for f in self._ids.values() if len(f._parents) > 1)
1412 @readonly
1413 def TotalHardLinkCount3(self) -> int:
1414 """
1415 Read-only property to return the number of file storage objects that are **not** hardlinked.
1417 .. attention::
1419 Despite the name, this counts files referenced by exactly *one* directory entry.
1421 :returns: Number of files referenced by exactly one directory entry.
1422 """
1423 return sum(1 for f in self._ids.values() if len(f._parents) == 1)
1425 @readonly
1426 def Size2(self) -> int:
1427 """
1428 Read-only property to return the accumulated size of all hardlinked files, counted once each.
1430 :returns: Sum of sizes over all files referenced by more than one directory entry.
1431 """
1432 return sum(f._size for f in self._ids.values() if len(f._parents) > 1)
1434 @readonly
1435 def Size3(self) -> int:
1436 """
1437 Read-only property to return the accumulated size of all hardlinked files, counted per directory entry.
1439 In contrast to :attr:`Size2`, a file's size is multiplied by the number of directory entries
1440 referencing it, so it reflects the size a filesystem without hardlink support would need.
1442 :returns: Sum of sizes over all hardlinked files, weighted by their number of directory entries.
1443 """
1444 return sum(f._size * len(f._parents) for f in self._ids.values() if len(f._parents) > 1)
1446 @readonly
1447 def TotalUniqueFileCount(self) -> int:
1448 """
1449 Read-only property to return the number of distinct file storage objects, counting hardlinks to the same content once.
1451 :returns: Number of unique files.
1452 """
1453 return len(self._ids)
1455 def RegisterBrokenSymbolicLink(self, symLink: SymbolicLink) -> None:
1456 """
1457 Mark a symbolic link as broken and collect it at the root.
1459 :param symLink: The symbolic link whose target doesn't exist.
1460 """
1461 symLink._isBroken = True
1462 self._brokenSymbolicLinks.append(symLink)
1464 def RegisterUnconnectedSymbolicLink(self, symLink: SymbolicLink) -> None:
1465 """
1466 Mark a symbolic link as out of range and collect it at the root.
1468 :param symLink: The symbolic link whose target lies outside the scanned tree.
1469 """
1470 symLink._isOutOfRange = True
1471 self._unconnectedSymbolicLinks.append(symLink)
1473 def Copy(self) -> Root:
1474 """
1475 Copy the directory structure including all subelements and link it to the given parent.
1477 The duration for the deep copy process is provided in :attr:`ScanDuration`
1479 .. hint::
1481 Statistics like aggregated directory size are copied too. |br|
1482 There is no rescan or repeated aggregation needed.
1484 :returns: A deep copy of the directory structure.
1485 """
1486 with Stopwatch() as sw:
1487 root = Root(self._path, False)
1488 root._size = self._size
1490 for subdir in self._subdirectories.values():
1491 subdir.Copy(root)
1493 for file in self._files.values():
1494 file.Copy(root)
1496 for link in self._symbolicLinks.values():
1497 link.Copy(root)
1499 root._scanDuration = sw.Duration
1500 root._aggregateDuration = 0.0
1502 return root
1504 def __repr__(self) -> str:
1505 """
1506 Return a detailed string representation of this filesystem root.
1508 :returns: The root's path and the number of directories, regular files and symbolic links below it.
1509 """
1510 return f"Root: {self.Path} (dirs: {self.TotalSubdirectoryCount}, files: {self.TotalRegularFileCount}, symlinks: {self.TotalSymbolicLinkCount})"
1512 def __str__(self) -> str:
1513 """
1514 Return a string representation of this filesystem element.
1516 :returns: The element's name, without any path.
1517 """
1518 return self._name
1521@export
1522class File(Base):
1523 """
1524 A **File** represents a file storage object in the filesystem, which is accessible by one or more :class:`Filename` objects.
1526 Each file has an internal id, which is associated to a unique ID within the host's filesystem.
1527 """
1528 _id: int #: Unique (host internal) file object ID)
1529 _parents: list[Filename] #: List of reverse references to :class:`Filename` objects.
1531 def __init__(
1532 self,
1533 id: int,
1534 size: int,
1535 parent: Nullable[Filename] = None
1536 ) -> None:
1537 """
1538 Initialize the File storage object with an ID, size and parent reference.
1540 :param id: Unique ID of the file object.
1541 :param size: Size of the file object.
1542 :param parent: Optional, parent reference.
1543 :raises ValueError: If parameter 'id' is None.
1544 :raises TypeError: If parameter 'parent' is not of type :class:`Filename`.
1545 """
1546 if id is None: 1546 ↛ 1547line 1546 didn't jump to line 1547 because the condition on line 1546 was never true
1547 raise ValueError("Parameter 'id' is None.")
1548 elif not isinstance(id, int): 1548 ↛ 1549line 1548 didn't jump to line 1549 because the condition on line 1548 was never true
1549 ex = TypeError("Parameter 'id' is not of type 'int'.")
1550 ex.add_note(f"Got type '{getFullyQualifiedName(id)}'.")
1551 raise ex
1553 self._id = id
1555 if parent is None:
1556 super().__init__(size, None)
1557 self._parents = []
1558 elif isinstance(parent, Filename): 1558 ↛ 1563line 1558 didn't jump to line 1563 because the condition on line 1558 was always true
1559 super().__init__(size, parent._root)
1560 self._parents = [parent]
1561 parent._file = self
1562 else:
1563 ex = TypeError("Parameter 'parent' is not of type 'Filename'.")
1564 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
1565 raise ex
1567 @readonly
1568 def ID(self) -> int:
1569 """
1570 Read-only property to access the file object's unique identifier.
1572 :returns: Unique file object identifier.
1573 """
1574 return self._id
1576 @readonly
1577 def Parents(self) -> list[Filename]:
1578 """
1579 Read-only property to access the list of filenames using the same file storage object.
1581 .. hint::
1583 This allows to check if a file object has multiple filenames a.k.a hardlinks.
1585 :returns: List of filenames for the file storage object.
1586 """
1587 return self._parents
1589 def AddParent(self, filename: Filename) -> None:
1590 """
1591 Add another parent reference to a :class:`Filename`.
1593 :param filename: Reference to a filename object.
1594 :raises ValueError: If parameter 'filename' is None.
1595 :raises TypeError: If parameter 'filename' is not of type :class:`Filename`.
1596 :raises ToolingException: If the filename already references another file object.
1597 """
1598 if filename is None: 1598 ↛ 1599line 1598 didn't jump to line 1599 because the condition on line 1598 was never true
1599 raise ValueError("Parameter 'filename' is None.")
1600 elif not isinstance(filename, Filename): 1600 ↛ 1601line 1600 didn't jump to line 1601 because the condition on line 1600 was never true
1601 ex = TypeError("Parameter 'filename' is not of type 'Filename'.")
1602 ex.add_note(f"Got type '{getFullyQualifiedName(filename)}'.")
1603 raise ex
1604 elif filename._file is not None: 1604 ↛ 1605line 1604 didn't jump to line 1605 because the condition on line 1604 was never true
1605 raise ToolingException(f"Filename is already referencing an other file object ({filename._file._id}).")
1607 self._parents.append(filename)
1608 filename._file = self
1610 if filename._root is not None:
1611 self._root = filename._root
1613 def ToTree(self) -> Node:
1614 """
1615 Convert this file storage object to a node of a :mod:`pyTooling.Tree`.
1617 The node's :attr:`~pyTooling.Tree.Node.Value` field contains a reference to the file object. Additional data is
1618 attached to the node's key-value store:
1620 ``kind``
1621 The node's kind: :attr:`NodeKind.File`.
1622 ``size``
1623 The file object's size.
1625 :returns: A tree node carrying this file object, its kind and its size.
1626 """
1627 def format(node: Node) -> str:
1628 """
1629 Nested function rendering a tree node as one line.
1631 :param node: The tree node to render.
1632 :returns: The node's size in MiB, followed by the file object's ID.
1633 """
1634 return f"{node['size'] * 1e-6:7.1f} MiB file object {node._value.ID}"
1636 fileNode: Node[Any, Any, Any, Any] = Node(
1637 value=self,
1638 keyValuePairs={
1639 "kind": NodeKind.File,
1640 "size": self._size
1641 },
1642 format=format
1643 )
1645 return fileNode