Coverage for pyTooling / Filesystem / __init__.py: 48%

485 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-08 23:46 +0000

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. 

33 

34.. important:: 

35 

36 This isn't a replacement of :mod:`pathlib` introduced with Python 3.4. 

37""" 

38from os import scandir, readlink 

39 

40from enum import Enum 

41from itertools import chain 

42from pathlib import Path 

43from typing import Optional as Nullable, Dict, Generic, Generator, TypeVar, List, Any, Callable, Union 

44 

45try: 

46 from pyTooling.Decorators import readonly, export 

47 from pyTooling.Exceptions import ToolingException 

48 from pyTooling.MetaClasses import ExtendedType, abstractmethod 

49 from pyTooling.Common import getFullyQualifiedName, zipdicts 

50 from pyTooling.Stopwatch import Stopwatch 

51 from pyTooling.Tree import Node 

52except (ImportError, ModuleNotFoundError): # pragma: no cover 

53 print("[pyTooling.Filesystem] Could not import from 'pyTooling.*'!") 

54 

55 try: 

56 from pyTooling.Decorators import readonly, export 

57 from pyTooling.Exceptions import ToolingException 

58 from pyTooling.MetaClasses import ExtendedType, abstractmethod 

59 from pyTooling.Common import getFullyQualifiedName 

60 from pyTooling.Stopwatch import Stopwatch 

61 from pyTooling.Tree import Node 

62 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover 

63 print("[pyTooling.Filesystem] Could not import directly!") 

64 raise ex 

65 

66 

67__all__ = ["_ParentType"] 

68 

69_ParentType = TypeVar("_ParentType", bound="Element") 

70"""The type variable for a parent reference.""" 

71 

72 

73@export 

74class FilesystemException(ToolingException): 

75 """Base-exception of all exceptions raised by :mod:`pyTooling.Filesystem`.""" 

76 

77 

78@export 

79class NodeKind(Enum): 

80 """ 

81 Node kind for filesystem elements in a :ref:`tree <STRUCT/Tree>`. 

82 

83 This enumeration is used when converting the filesystem statistics tree to an instance of :mod:`pyTooling.Tree`. 

84 """ 

85 Directory = 0 #: Node represents a directory. 

86 File = 1 #: Node represents a regular file. 

87 SymbolicLink = 2 #: Node represents a symbolic link. 

88 

89 

90@export 

91class Base(metaclass=ExtendedType, slots=True): 

92 """ 

93 Base-class for all filesystem elements in :mod:`pyTooling.Filesystem`. 

94 

95 It implements a size and a reference to the root element of the filesystem. 

96 """ 

97 _root: Nullable["Root"] #: Reference to the root of the filesystem statistics scope. 

98 _size: Nullable[int] #: Actual or aggregated size of the filesystem element. 

99 

100 def __init__( 

101 self, 

102 size: Nullable[int], 

103 root: Nullable["Root"] 

104 ) -> None: 

105 """ 

106 Initialize the base-class with filesystem element size and root reference. 

107 

108 :param size: Optional size of the element. 

109 :param root: Optional reference to the filesystem root element. 

110 """ 

111 if size is None: 

112 pass 

113 elif not isinstance(size, int): 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true

114 ex = TypeError("Parameter 'size' is not of type 'int'.") 

115 ex.add_note(f"Got type '{getFullyQualifiedName(size)}'.") 

116 raise ex 

117 

118 self._size = size 

119 self._root = root 

120 

121 @property 

122 def Root(self) -> Nullable["Root"]: 

123 """ 

124 Property to access the root of the filesystem statistics scope. 

125 

126 :returns: Root of the filesystem statistics scope. 

127 """ 

128 return self._root 

129 

130 @Root.setter 

131 def Root(self, value: "Root") -> None: 

132 self._root = value 

133 

134 @readonly 

135 def Size(self) -> int: 

136 """ 

137 Read-only property to access the element's size in Bytes. 

138 

139 :returns: Size in Bytes. 

140 :raises FilesystemException: If size is not computed, yet. 

141 """ 

142 if self._size is None: 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true

143 raise FilesystemException("Size is not computed, yet.") 

144 

145 return self._size 

146 

147 # FIXME: @abstractmethod 

148 def ToTree(self) -> Node: 

149 """ 

150 Convert a filesystem element to a node in :mod:`pyTooling.Tree`. 

151 

152 The node's :attr:`~pyTooling.Tree.Node.Value` field contains a reference to the filesystem element. Additional data 

153 will be stored in the node's key-value store. 

154 

155 :returns: A tree's node referencing this filesystem element. 

156 """ 

157 raise NotImplementedError() 

158 

159 

160@export 

161class Element(Base, Generic[_ParentType]): 

162 """ 

163 Base-class for all named elements within a filesystem. 

164 

165 It adds a name, parent reference and list of symbolic-link sources. 

166 

167 .. hint:: 

168 

169 Symbolic link sources are reverse references describing which symbolic links point to this element. 

170 """ 

171 _name: str #: Name of the filesystem element. 

172 _parent: _ParentType #: Reference to the filesystem element's parent (:class:`Directory`) 

173 _linkSources: List["SymbolicLink"] #: A list of symbolic links pointing to this filesystem element. 

174 

175 def __init__( 

176 self, 

177 name: str, 

178 size: Nullable[int] = None, 

179 parent: Nullable[_ParentType] = None 

180 ) -> None: 

181 """ 

182 Initialize the element base-class with name, size and parent reference. 

183 

184 :param name: Name of the element. 

185 :param size: Optional size of the element. 

186 :param parent: Optional parent reference. 

187 """ 

188 root = None # FIXME: if parent is None else parent._root 

189 

190 super().__init__(size, root) 

191 

192 self._parent = parent 

193 self._name = name 

194 self._linkSources = [] 

195 

196 @property 

197 def Parent(self) -> _ParentType: 

198 """ 

199 Property to access the element's parent. 

200 

201 :returns: Parent element. 

202 """ 

203 return self._parent 

204 

205 @Parent.setter 

206 def Parent(self, value: _ParentType) -> None: 

207 self._parent = value 

208 

209 if value._root is not None: 

210 self._root = value._root 

211 

212 @readonly 

213 def Name(self) -> str: 

214 """ 

215 Read-only property to access the element's name. 

216 

217 :returns: Element name. 

218 """ 

219 return self._name 

220 

221 @readonly 

222 def Path(self) -> Path: 

223 raise NotImplemented(f"Property 'Path' is abstract.") 

224 

225 def AddLinkSources(self, source: "SymbolicLink") -> None: 

226 """ 

227 Add a link source of a symbolic link to the named element (reverse reference). 

228 

229 :param source: The referenced symbolic link. 

230 """ 

231 if not isinstance(source, SymbolicLink): 

232 ex = TypeError("Parameter 'source' is not of type 'SymbolicLink'.") 

233 ex.add_note(f"Got type '{getFullyQualifiedName(source)}'.") 

234 raise ex 

235 

236 self._linkSources.append(source) 

237 

238 

239@export 

240class Directory(Element["Directory"]): 

241 """ 

242 A **directory** represents a directory in the filesystem, which contains subdirectories, regular files and symbolic links. 

243 

244 While scanning for subelements, the directory is populated with elements. Every file object added, gets registered in 

245 the filesystems :class:`Root` for deduplication. In case a file identifier already exists, the found filename will 

246 reference the same file objects. In turn, the file objects has then references to multiple filenames (parents). This 

247 allows to detect :term:`hardlinks <hardlink>`. 

248 

249 The time needed for scanning the directory and its subelements is provided via :data:`ScanDuration`. 

250 

251 After scnaning the directory for subelements, certain directory properties get aggregated. The time needed for 

252 aggregation is provided via :data:`AggregateDuration`. 

253 """ 

254 

255 _path: Nullable[Path] #: Cached :class:`~pathlib.Path` object of this directory. 

256 _subdirectories: Dict[str, "Directory"] #: Dictionary containing name-:class:`Directory` pairs. 

257 _files: Dict[str, "Filename"] #: Dictionary containing name-:class:`Filename` pairs. 

258 _symbolicLinks: Dict[str, "SymbolicLink"] #: Dictionary containing name-:class:`SymbolicLink` pairs. 

259 _collapsed: bool #: True, if this directory was collapsed. It contains no subelements. 

260 _scanDuration: Nullable[float] #: Duration for scanning the directory and all its subelements. 

261 _aggregateDuration: Nullable[float] #: Duration for aggregating all subelements. 

262 

263 def __init__( 

264 self, 

265 name: str, 

266 collectSubdirectories: bool = False, 

267 parent: Nullable["Directory"] = None 

268 ) -> None: 

269 """ 

270 Initialize the directory with name and parent reference. 

271 

272 :param name: Name of the element. 

273 :param collectSubdirectories: If true, collect subdirectory statistics. 

274 :param parent: Optional parent reference. 

275 """ 

276 super().__init__(name, None, parent) 

277 

278 self._path = None 

279 self._subdirectories = {} 

280 self._files = {} 

281 self._symbolicLinks = {} 

282 self._collapsed = False 

283 self._scanDuration = None 

284 self._aggregateDuration = None 

285 

286 if parent is not None: 

287 parent._subdirectories[name] = self 

288 

289 if parent._root is not None: 289 ↛ 292line 289 didn't jump to line 292 because the condition on line 289 was always true

290 self._root = parent._root 

291 

292 if collectSubdirectories: 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true

293 self._collectSubdirectories() 

294 

295 def _collectSubdirectories(self) -> None: 

296 """ 

297 Helper method for scanning subdirectories and aggregating found element sizes therein. 

298 """ 

299 with Stopwatch() as sw1: 

300 self._scanSubdirectories() 

301 

302 with Stopwatch() as sw2: 

303 self._aggregateSizes() 

304 

305 self._scanDuration = sw1.Duration 

306 self._aggregateDuration = sw2.Duration 

307 

308 def _scanSubdirectories(self) -> None: 

309 """ 

310 Helper method for scanning subdirectories (recursively) and building a 

311 :class:`Directory`-:class:`Filename`-:class:`File` object tree. 

312 

313 If a file refers to the same filesystem internal unique ID, a hardlink (two or more filenames) to the same file 

314 storage object is assumed. 

315 """ 

316 try: 

317 items = scandir(directoryPath := self.Path) 

318 except PermissionError as ex: 

319 return 

320 

321 for dirEntry in items: 

322 if dirEntry.is_dir(follow_symlinks=False): 

323 subdirectory = Directory(dirEntry.name, collectSubdirectories=True, parent=self) 

324 elif dirEntry.is_file(follow_symlinks=False): 

325 id = dirEntry.inode() 

326 if id in self._root._ids: 

327 file = self._root._ids[id] 

328 

329 hardLink = Filename(dirEntry.name, file=file, parent=self) 

330 else: 

331 s = dirEntry.stat(follow_symlinks=False) 

332 filename = Filename(dirEntry.name, parent=self) 

333 file = File(id, s.st_size, parent=filename) 

334 

335 self._root._ids[id] = file 

336 elif dirEntry.is_symlink(): 

337 target = Path(readlink(directoryPath / dirEntry.name)) 

338 symlink = SymbolicLink(dirEntry.name, target, parent=self) 

339 else: 

340 raise FilesystemException(f"Unknown directory element.") 

341 

342 def _connectSymbolicLinks(self) -> None: 

343 for dir in self._subdirectories.values(): 

344 dir._connectSymbolicLinks() 

345 

346 for link in self._symbolicLinks.values(): 

347 if link._target.is_absolute(): 

348 pass 

349 else: 

350 target = self 

351 for elem in link._target.parts: 

352 if elem == ".": 

353 continue 

354 elif elem == "..": 

355 target = target._parent 

356 continue 

357 

358 try: 

359 target = target._subdirectories[elem] 

360 continue 

361 except KeyError: 

362 pass 

363 

364 try: 

365 target = target._files[elem] 

366 continue 

367 except KeyError: 

368 pass 

369 

370 try: 

371 target = target._symbolicLinks[elem] 

372 continue 

373 except KeyError: 

374 pass 

375 

376 target.AddLinkSources(link) 

377 

378 def _aggregateSizes(self) -> None: 

379 self._size = ( 

380 sum(dir._size for dir in self._subdirectories.values()) + 

381 sum(file._file._size for file in self._files.values()) 

382 ) 

383 

384 @Element.Root.setter 

385 def Root(self, value: "Root") -> None: 

386 Element.Root.fset(self, value) 

387 

388 for subdir in self._subdirectories.values(): 388 ↛ 389line 388 didn't jump to line 389 because the loop on line 388 never started

389 subdir.Root = value 

390 

391 for file in self._files.values(): 

392 file.Root = value 

393 

394 for link in self._symbolicLinks.values(): 394 ↛ 395line 394 didn't jump to line 395 because the loop on line 394 never started

395 link.Root = value 

396 

397 @Element.Parent.setter 

398 def Parent(self, value: _ParentType) -> None: 

399 Element.Parent.fset(self, value) 

400 

401 value._subdirectories[self._name] = self 

402 

403 if isinstance(value, Root): 403 ↛ exitline 403 didn't return from function 'Parent' because the condition on line 403 was always true

404 self.Root = value 

405 

406 @readonly 

407 def Count(self) -> int: 

408 """ 

409 Read-only property to access the number of elements in a directory. 

410 

411 :returns: Number of files plus subdirectories. 

412 """ 

413 return len(self._subdirectories) + len(self._files) + len(self._symbolicLinks) 

414 

415 @readonly 

416 def FileCount(self) -> int: 

417 """ 

418 Read-only property to access the number of files in a directory. 

419 

420 .. hint:: 

421 

422 Files include regular files and symbolic links. 

423 

424 :returns: Number of files. 

425 """ 

426 return len(self._files) + len(self._symbolicLinks) 

427 

428 @readonly 

429 def RegularFileCount(self) -> int: 

430 """ 

431 Read-only property to access the number of regular files in a directory. 

432 

433 :returns: Number of regular files. 

434 """ 

435 return len(self._files) 

436 

437 @readonly 

438 def SymbolicLinkCount(self) -> int: 

439 """ 

440 Read-only property to access the number of symbolic links in a directory. 

441 

442 :returns: Number of symbolic links. 

443 """ 

444 return len(self._symbolicLinks) 

445 

446 @readonly 

447 def SubdirectoryCount(self) -> int: 

448 """ 

449 Read-only property to access the number of subdirectories in a directory. 

450 

451 :returns: Number of subdirectories. 

452 """ 

453 return len(self._subdirectories) 

454 

455 @readonly 

456 def TotalFileCount(self) -> int: 

457 """ 

458 Read-only property to access the total number of files in all child hierarchy levels (recursively). 

459 

460 .. hint:: 

461 

462 Files include regular files and symbolic links. 

463 

464 :returns: Total number of files. 

465 """ 

466 return sum(d.TotalFileCount for d in self._subdirectories.values()) + len(self._files) + len(self._symbolicLinks) 

467 

468 @readonly 

469 def TotalRegularFileCount(self) -> int: 

470 """ 

471 Read-only property to access the total number of regular files in all child hierarchy levels (recursively). 

472 

473 :returns: Total number of regular files. 

474 """ 

475 return sum(d.TotalRegularFileCount for d in self._subdirectories.values()) + len(self._files) 

476 

477 @readonly 

478 def TotalSymbolicLinkCount(self) -> int: 

479 """ 

480 Read-only property to access the total number of symbolic links in all child hierarchy levels (recursively). 

481 

482 :returns: Total number of symbolic links. 

483 """ 

484 return sum(d.TotalSymbolicLinkCount for d in self._subdirectories.values()) + len(self._symbolicLinks) 

485 

486 @readonly 

487 def TotalSubdirectoryCount(self) -> int: 

488 """ 

489 Read-only property to access the total number of subdirectories in all child hierarchy levels (recursively). 

490 

491 :returns: Total number of subdirectories. 

492 """ 

493 return len(self._subdirectories) + sum(d.TotalSubdirectoryCount for d in self._subdirectories.values()) 

494 

495 @readonly 

496 def Subdirectories(self) -> Generator["Directory", None, None]: 

497 """ 

498 Iterate all direct subdirectories of the directory. 

499 

500 :returns: A generator to iterate all direct subdirectories. 

501 """ 

502 return (d for d in self._subdirectories.values()) 

503 

504 @readonly 

505 def Files(self) -> Generator[Union["Filename", "SymbolicLink"], None, None]: 

506 """ 

507 Iterate all direct files of the directory. 

508 

509 .. hint:: 

510 

511 Files include regular files and symbolic links. 

512 

513 :returns: A generator to iterate all direct files. 

514 """ 

515 return (f for f in chain(self._files.values(), self._symbolicLinks.values())) 

516 

517 @readonly 

518 def RegularFiles(self) -> Generator["Filename", None, None]: 

519 """ 

520 Iterate all direct regular files of the directory. 

521 

522 :returns: A generator to iterate all direct regular files. 

523 """ 

524 return (f for f in self._files.values()) 

525 

526 @readonly 

527 def SymbolicLinks(self) -> Generator["SymbolicLink", None, None]: 

528 """ 

529 Iterate all direct symbolic links of the directory. 

530 

531 :returns: A generator to iterate all direct symbolic links. 

532 """ 

533 return (l for l in self._symbolicLinks.values()) 

534 

535 @readonly 

536 def Path(self) -> Path: 

537 """ 

538 Read-only property to access the equivalent Path instance for accessing the represented directory. 

539 

540 :returns: Path to the directory. 

541 :raises FilesystemException: If no parent is set. 

542 """ 

543 if self._path is not None: 

544 return self._path 

545 

546 if self._parent is None: 

547 raise FilesystemException(f"No parent or root set for directory.") 

548 

549 self._path = self._parent.Path / self._name 

550 return self._path 

551 

552 @readonly 

553 def ScanDuration(self) -> float: 

554 """ 

555 Read-only property to access the time needed to scan a directory structure including all subelements (recursively). 

556 

557 :returns: The scan duration in seconds. 

558 :raises FilesystemException: If the directory was not scanned. 

559 """ 

560 if self._scanDuration is None: 

561 raise FilesystemException(f"Directory was not scanned, yet.") 

562 

563 return self._scanDuration 

564 

565 @readonly 

566 def AggregateDuration(self) -> float: 

567 """ 

568 Read-only property to access the time needed to aggregate the directory's and subelement's properties (recursively). 

569 

570 :returns: The aggregation duration in seconds. 

571 :raises FilesystemException: If the directory properties were not aggregated. 

572 """ 

573 if self._scanDuration is None: 

574 raise FilesystemException(f"Directory properties were not aggregated, yet.") 

575 

576 return self._aggregateDuration 

577 

578 def Copy(self, parent: Nullable["Directory"] = None) -> "Directory": 

579 """ 

580 Copy the directory structure including all subelements and link it to the given parent. 

581 

582 .. hint:: 

583 

584 Statistics like aggregated directory size are copied too. |br| 

585 There is no rescan or repeated aggregation needed. 

586 

587 :param parent: The parent element of the copied directory. 

588 :returns: A deep copy of the directory structure. 

589 """ 

590 dir = Directory(self._name, parent=parent) 

591 dir._size = self._size 

592 

593 for subdir in self._subdirectories.values(): 

594 subdir.Copy(dir) 

595 

596 for file in self._files.values(): 

597 file.Copy(dir) 

598 

599 for link in self._symbolicLinks.values(): 

600 link.Copy(dir) 

601 

602 return dir 

603 

604 def Collapse(self, func: Callable[["Directory"], bool]) -> bool: 

605 # if len(self._subdirectories) == 0 or all(subdir.Collapse(func) for subdir in self._subdirectories.values()): 

606 if len(self._subdirectories) == 0: 

607 if func(self): 

608 # print(f"collapse 1 {self.Path}") 

609 self._collapsed = True 

610 self._subdirectories.clear() 

611 self._files.clear() 

612 self._symbolicLinks.clear() 

613 

614 return True 

615 else: 

616 return False 

617 

618 # if all(subdir.Collapse(func) for subdir in self._subdirectories.values()) 

619 collapsible = True 

620 for subdir in self._subdirectories.values(): 

621 result = subdir.Collapse(func) 

622 collapsible = collapsible and result 

623 

624 if collapsible: 

625 # print(f"collapse 2 {self.Path}") 

626 self._collapsed = True 

627 self._subdirectories.clear() 

628 self._files.clear() 

629 self._symbolicLinks.clear() 

630 

631 return True 

632 else: 

633 return False 

634 

635 def ToTree(self, format: Nullable[Callable[[Node], str]] = None) -> Node: 

636 """ 

637 Convert the directory to a :class:`~pyTooling.Tree.Node`. 

638 

639 The node's :attr:`~pyTooling.Tree.Node.Value` field contains a reference to the directory. Additional data is 

640 attached to the node's key-value store: 

641 

642 ``kind`` 

643 The node's kind. See :class:`NodeKind`. 

644 ``size`` 

645 The directory's aggregated size. 

646 

647 :param format: A user defined formatting function for tree nodes. 

648 :returns: A tree node representing this directory. 

649 """ 

650 if format is None: 

651 def format(node: Node) -> str: 

652 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}" 

653 

654 directoryNode = Node( 

655 value=self, 

656 keyValuePairs={ 

657 "kind": NodeKind.File, 

658 "size": self._size 

659 }, 

660 format=format 

661 ) 

662 directoryNode.AddChildren( 

663 e.ToTree(format) for e in chain(self._subdirectories.values()) #, self._files.values(), self._symbolicLinks.values()) 

664 ) 

665 

666 return directoryNode 

667 

668 def __eq__(self, other) -> bool: 

669 """ 

670 Compare two Directory instances for equality. 

671 

672 :param other: Parameter to compare against. 

673 :returns: ``True``, if both directories and all its subelements are equal. 

674 :raises TypeError: If parameter ``other`` is not of type :class:`Directory`. 

675 """ 

676 if not isinstance(other, Directory): 

677 ex = TypeError("Parameter 'other' is not of type Directory.") 

678 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

679 raise ex 

680 

681 if not all(dir1 == dir2 for _, dir1, dir2 in zipdicts(self._subdirectories, other._subdirectories)): 

682 return False 

683 

684 if not all(file1 == file2 for _, file1, file2 in zipdicts(self._files, other._files)): 

685 return False 

686 

687 if not all(link1 == link2 for _, link1, link2 in zipdicts(self._symbolicLinks, other._symbolicLinks)): 

688 return False 

689 

690 return True 

691 

692 def __ne__(self, other: Any) -> bool: 

693 """ 

694 Compare two Directory instances for inequality. 

695 

696 :param other: Parameter to compare against. 

697 :returns: ``True``, if both directories and all its subelements are unequal. 

698 :raises TypeError: If parameter ``other`` is not of type :class:`Directory`. 

699 """ 

700 return not self.__eq__(other) 

701 

702 def __repr__(self) -> str: 

703 return f"Directory: {self.Path}" 

704 

705 def __str__(self) -> str: 

706 return self._name 

707 

708 

709@export 

710class Filename(Element[Directory]): 

711 """ 

712 Represents a filename in the filesystem, but not the file storage object (:class:`File`). 

713 

714 .. hint:: 

715 

716 Filename and file storage are represented by two classes, which allows multiple names (hard links) per file storage 

717 object. 

718 """ 

719 _file: Nullable["File"] 

720 

721 def __init__( 

722 self, 

723 name: str, 

724 file: Nullable["File"] = None, 

725 parent: Nullable[Directory] = None 

726 ) -> None: 

727 """ 

728 Initialize the filename with name, file (storage) object and parent reference. 

729 

730 :param name: Name of the file. 

731 :param size: Optional file (storage) object. 

732 :param parent: Optional parent reference. 

733 """ 

734 super().__init__(name, None, parent) 

735 

736 if file is None: 736 ↛ 739line 736 didn't jump to line 739 because the condition on line 736 was always true

737 self._file = None 

738 else: 

739 self._file = file 

740 file._parents.append(self) 

741 

742 if parent is not None: 

743 parent._files[name] = self 

744 

745 if parent._root is not None: 

746 self._root = parent._root 

747 

748 @Element.Root.setter 

749 def Root(self, value: "Root") -> None: 

750 self._root = value 

751 

752 if self._file is not None: 752 ↛ exitline 752 didn't return from function 'Root' because the condition on line 752 was always true

753 self._file._root = value 

754 

755 @Element.Parent.setter 

756 def Parent(self, value: _ParentType) -> None: 

757 Element.Parent.fset(self, value) 

758 

759 value._files[self._name] = self 

760 

761 if isinstance(value, Root): 761 ↛ 762line 761 didn't jump to line 762 because the condition on line 761 was never true

762 self.Root = value 

763 

764 @readonly 

765 def File(self) -> Nullable["File"]: 

766 return self._file 

767 

768 @readonly 

769 def Size(self) -> int: 

770 if self._file is None: 

771 raise ToolingException(f"Filename isn't linked to a File object.") 

772 

773 return self._file._size 

774 

775 @readonly 

776 def Path(self) -> Path: 

777 if self._parent is None: 

778 raise ToolingException(f"Filename has no parent object.") 

779 

780 return self._parent.Path / self._name 

781 

782 def Copy(self, parent: Directory) -> "Filename": 

783 fileID = self._file._id 

784 

785 if fileID in parent._root._ids: 

786 file = parent._root._ids[fileID] 

787 else: 

788 fileSize = self._file._size 

789 file = File(fileID, fileSize) 

790 

791 parent._root._ids[fileID] = file 

792 

793 return Filename(self._name, file, parent=parent) 

794 

795 def ToTree(self) -> Node: 

796 def format(node: Node) -> str: 

797 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}" 

798 

799 fileNode = Node( 

800 value=self, 

801 keyValuePairs={ 

802 "kind": NodeKind.File, 

803 "size": self._size 

804 }, 

805 format=format 

806 ) 

807 

808 return fileNode 

809 

810 def __eq__(self, other) -> bool: 

811 """ 

812 Compare two Filename instances for equality. 

813 

814 :param other: Parameter to compare against. 

815 :returns: ``True``, if both filenames are equal. 

816 :raises TypeError: If parameter ``other`` is not of type :class:`Filename`. 

817 """ 

818 if not isinstance(other, Filename): 

819 ex = TypeError("Parameter 'other' is not of type Filename.") 

820 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

821 raise ex 

822 

823 return self._name == other._name and self.Size == other.Size 

824 

825 def __ne__(self, other: Any) -> bool: 

826 """ 

827 Compare two Filename instances for inequality. 

828 

829 :param other: Parameter to compare against. 

830 :returns: ``True``, if both filenames are unequal. 

831 :raises TypeError: If parameter ``other`` is not of type :class:`Filename`. 

832 """ 

833 if not isinstance(other, Filename): 

834 ex = TypeError("Parameter 'other' is not of type Filename.") 

835 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

836 raise ex 

837 

838 return self._name != other._name or self.Size != other.Size 

839 

840 def __repr__(self) -> str: 

841 return f"File: {self.Path}" 

842 

843 def __str__(self) -> str: 

844 return self._name 

845 

846 

847@export 

848class SymbolicLink(Element[Directory]): 

849 _target: Path 

850 

851 def __init__( 

852 self, 

853 name: str, 

854 target: Path, 

855 parent: Nullable[Directory] 

856 ) -> None: 

857 super().__init__(name, None, parent) 

858 

859 self._target = target 

860 

861 if parent is not None: 

862 parent._symbolicLinks[name] = self 

863 

864 if parent._root is not None: 

865 self._root = parent._root 

866 

867 @readonly 

868 def Path(self) -> Path: 

869 return self._parent.Path / self._name 

870 

871 @readonly 

872 def Target(self) -> Path: 

873 return self._target 

874 

875 def Copy(self, parent: Directory) -> "SymbolicLink": 

876 return SymbolicLink(self._name, self._target, parent=parent) 

877 

878 def ToTree(self) -> Node: 

879 def format(node: Node) -> str: 

880 return f"{node['size'] * 1e-6:7.1f} MiB {node._value.Name}" 

881 

882 symbolicLinkNode = Node( 

883 value=self, 

884 keyValuePairs={ 

885 "kind": NodeKind.SymbolicLink, 

886 "size": self._size 

887 }, 

888 format=format 

889 ) 

890 

891 return symbolicLinkNode 

892 

893 def __eq__(self, other) -> bool: 

894 """ 

895 Compare two SymbolicLink instances for equality. 

896 

897 :param other: Parameter to compare against. 

898 :returns: ``True``, if both symbolic links are equal. 

899 :raises TypeError: If parameter ``other`` is not of type :class:`SymbolicLink`. 

900 """ 

901 if not isinstance(other, SymbolicLink): 

902 ex = TypeError("Parameter 'other' is not of type SymbolicLink.") 

903 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

904 raise ex 

905 

906 return self._name == other._name and self._target == other._target 

907 

908 def __ne__(self, other: Any) -> bool: 

909 """ 

910 Compare two SymbolicLink instances for inequality. 

911 

912 :param other: Parameter to compare against. 

913 :returns: ``True``, if both symbolic links are unequal. 

914 :raises TypeError: If parameter ``other`` is not of type :class:`SymbolicLink`. 

915 """ 

916 if not isinstance(other, SymbolicLink): 

917 ex = TypeError("Parameter 'other' is not of type SymbolicLink.") 

918 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

919 raise ex 

920 

921 return self._name != other._name or self._target != other._target 

922 

923 def __repr__(self) -> str: 

924 return f"SymLink: {self.Path} -> {self._target}" 

925 

926 def __str__(self) -> str: 

927 return self._name 

928 

929 

930@export 

931class Root(Directory): 

932 """ 

933 A **Root** represents the root-directory in the filesystem, which contains subdirectories, regular files and symbolic links. 

934 """ 

935 _ids: Dict[int, "File"] #: Dictionary of file identifier - file objects pairs found while scanning the directory structure. 

936 

937 def __init__( 

938 self, 

939 rootDirectory: Path, 

940 collectSubdirectories: bool = True 

941 ) -> None: 

942 if rootDirectory is None: 942 ↛ 943line 942 didn't jump to line 943 because the condition on line 942 was never true

943 raise ValueError(f"Parameter 'rootDirectory' is None.") 

944 elif not isinstance(rootDirectory, Path): 944 ↛ 945line 944 didn't jump to line 945 because the condition on line 944 was never true

945 raise TypeError(f"Parameter 'rootDirectory' is not of type 'Path'.") 

946 elif not rootDirectory.exists(): 946 ↛ 947line 946 didn't jump to line 947 because the condition on line 946 was never true

947 raise ToolingException(f"Path '{rootDirectory}' doesn't exist.") from FileNotFoundError(rootDirectory) 

948 

949 self._ids = {} 

950 

951 super().__init__(rootDirectory.name) 

952 self._root = self 

953 self._path = rootDirectory 

954 

955 if collectSubdirectories: 955 ↛ 956line 955 didn't jump to line 956 because the condition on line 955 was never true

956 self._collectSubdirectories() 

957 self._connectSymbolicLinks() 

958 

959 @readonly 

960 def TotalHardLinkCount(self) -> int: 

961 return sum(l for f in self._ids.values() if (l := len(f._parents)) > 1) 

962 

963 @readonly 

964 def TotalHardLinkCount2(self) -> int: 

965 return sum(1 for f in self._ids.values() if len(f._parents) > 1) 

966 

967 @readonly 

968 def TotalHardLinkCount3(self) -> int: 

969 return sum(1 for f in self._ids.values() if len(f._parents) == 1) 

970 

971 @readonly 

972 def Size2(self) -> int: 

973 return sum(f._size for f in self._ids.values() if len(f._parents) > 1) 

974 

975 @readonly 

976 def Size3(self) -> int: 

977 return sum(f._size * len(f._parents) for f in self._ids.values() if len(f._parents) > 1) 

978 

979 @readonly 

980 def TotalUniqueFileCount(self) -> int: 

981 return len(self._ids) 

982 

983 @readonly 

984 def Path(self) -> Path: 

985 """ 

986 Read-only property to access the path of the filesystem statistics root. 

987 

988 :returns: Path to the root of the filesystem statistics root directory. 

989 """ 

990 return self._path 

991 

992 def Copy(self) -> "Root": 

993 """ 

994 Copy the directory structure including all subelements and link it to the given parent. 

995 

996 The duration for the deep copy process is provided in :attr:`ScanDuration` 

997 

998 .. hint:: 

999 

1000 Statistics like aggregated directory size are copied too. |br| 

1001 There is no rescan or repeated aggregation needed. 

1002 

1003 :returns: A deep copy of the directory structure. 

1004 """ 

1005 with Stopwatch() as sw: 

1006 root = Root(self._path, False) 

1007 root._size = self._size 

1008 

1009 for subdir in self._subdirectories.values(): 

1010 subdir.Copy(root) 

1011 

1012 for file in self._files.values(): 

1013 file.Copy(root) 

1014 

1015 for link in self._symbolicLinks.values(): 

1016 link.Copy(root) 

1017 

1018 root._scanDuration = sw.Duration 

1019 root._aggregateDuration = 0.0 

1020 

1021 return root 

1022 

1023 def __repr__(self) -> str: 

1024 return f"Root: {self.Path} (dirs: {self.TotalSubdirectoryCount}, files: {self.TotalRegularFileCount}, symlinks: {self.TotalSymbolicLinkCount})" 

1025 

1026 def __str__(self) -> str: 

1027 return self._name 

1028 

1029 

1030@export 

1031class File(Base): 

1032 """ 

1033 A **File** represents a file storage object in the filesystem, which is accessible by one or more :class:`Filename` objects. 

1034 

1035 Each file has an internal id, which is associated to a unique ID within the host's filesystem. 

1036 """ 

1037 _id: int #: Unique (host internal) file object ID) 

1038 _parents: List[Filename] #: List of reverse references to :class:`Filename` objects. 

1039 

1040 def __init__( 

1041 self, 

1042 id: int, 

1043 size: int, 

1044 parent: Nullable[Filename] = None 

1045 ) -> None: 

1046 """ 

1047 Initialize the File storage object with an ID, size and parent reference. 

1048 

1049 :param id: Unique ID of the file object. 

1050 :param size: Size of the file object. 

1051 :param parent: Optional parent reference. 

1052 """ 

1053 if not isinstance(id, int): 1053 ↛ 1054line 1053 didn't jump to line 1054 because the condition on line 1053 was never true

1054 ex = TypeError("Parameter 'id' is not of type 'int'.") 

1055 ex.add_note(f"Got type '{getFullyQualifiedName(id)}'.") 

1056 raise ex 

1057 

1058 self._id = id 

1059 

1060 if parent is None: 

1061 super().__init__(size, None) 

1062 self._parents = [] 

1063 elif isinstance(parent, Filename): 1063 ↛ 1068line 1063 didn't jump to line 1068 because the condition on line 1063 was always true

1064 super().__init__(size, parent._root) 

1065 self._parents = [parent] 

1066 parent._file = self 

1067 else: 

1068 ex = TypeError("Parameter 'parent' is not of type 'Filename'.") 

1069 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

1070 raise ex 

1071 

1072 @readonly 

1073 def ID(self) -> int: 

1074 """ 

1075 Read-only property to access the file object's unique identifier. 

1076 

1077 :returns: Unique file object identifier. 

1078 """ 

1079 return self._id 

1080 

1081 @readonly 

1082 def Parents(self) -> List[Filename]: 

1083 """ 

1084 Read-only property to access the list of filenames using the same file storage object. 

1085 

1086 .. hint:: 

1087 

1088 This allows to check if a file object has multiple filenames a.k.a hardlinks. 

1089 

1090 :returns: List of filenames for the file storage object. 

1091 """ 

1092 return self._parents 

1093 

1094 def AddParent(self, file: Filename) -> None: 

1095 """ 

1096 Add another parent reference to a :class:`Filename`. 

1097 

1098 :param file: Reference to a filename object. 

1099 """ 

1100 if not isinstance(file, Filename): 1100 ↛ 1101line 1100 didn't jump to line 1101 because the condition on line 1100 was never true

1101 ex = TypeError("Parameter 'file' is not of type 'Filename'.") 

1102 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.") 

1103 raise ex 

1104 elif file._file is not None: 1104 ↛ 1105line 1104 didn't jump to line 1105 because the condition on line 1104 was never true

1105 raise ToolingException(f"Filename is already referencing an other file object ({file._file._id}).") 

1106 

1107 self._parents.append(file) 

1108 file._file = self 

1109 

1110 if file._root is not None: 

1111 self._root = file._root