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

463 statements  

« prev     ^ index     » next       coverage.py v7.11.1, created at 2025-11-07 22:21 +0000

1# ==================================================================================================================== # 

2# _____ _ _ _____ _ _ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | ___(_) | ___ ___ _ _ ___| |_ ___ _ __ ___ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |_ | | |/ _ \/ __| | | / __| __/ _ \ '_ ` _ \ # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| _| | | | __/\__ \ |_| \__ \ || __/ | | | | | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_| |_|_|\___||___/\__, |___/\__\___|_| |_| |_| # 

7# |_| |___/ |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2025-2025 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32An object-oriented file system abstraction for directory, file, symbolic link, ... statistics collection. 

33 

34.. important:: 

35 

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

37""" 

38from os import scandir, readlink 

39from sys import version_info 

40 

41from enum import Enum 

42from itertools import chain 

43from pathlib import Path 

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

45 

46try: 

47 from pyTooling.Decorators import readonly, export 

48 from pyTooling.Exceptions import ToolingException 

49 from pyTooling.MetaClasses import ExtendedType, abstractmethod 

50 from pyTooling.Common import getFullyQualifiedName, zipdicts 

51 from pyTooling.Stopwatch import Stopwatch 

52 from pyTooling.Tree import Node 

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

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

55 

56 try: 

57 from pyTooling.Decorators import readonly, export 

58 from pyTooling.Exceptions import ToolingException 

59 from pyTooling.MetaClasses import ExtendedType, abstractmethod 

60 from pyTooling.Common import getFullyQualifiedName 

61 from pyTooling.Stopwatch import Stopwatch 

62 from pyTooling.Tree import Node 

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

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

65 raise ex 

66 

67 

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

69 

70 

71@export 

72class FilesystemException(ToolingException): 

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

74 

75 

76@export 

77class NodeKind(Enum): 

78 """ 

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

80 

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

82 """ 

83 Directory = 0 #: Node represents a directory. 

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

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

86 

87 

88@export 

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

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

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

92 

93 def __init__( 

94 self, 

95 root: Nullable["Root"], 

96 size: Nullable[int], 

97 ) -> None: 

98 self._root = root 

99 self._size = size 

100 

101 @property 

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

103 """ 

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

105 

106 :returns: Root of the filesystem statistics scope. 

107 """ 

108 return self._root 

109 

110 @Root.setter 

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

112 self._root = value 

113 

114 @readonly 

115 def Size(self) -> int: 

116 """ 

117 Read-only property to access the elements size in Bytes. 

118 

119 :returns: Size in Bytes. 

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

121 """ 

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

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

124 

125 return self._size 

126 

127 # @abstractmethod 

128 def ToTree(self) -> Node: 

129 pass 

130 

131 

132@export 

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

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

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

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

137 

138 def __init__( 

139 self, 

140 name: str, 

141 size: Nullable[int] = None, 

142 parent: Nullable[_ParentType] = None 

143 ) -> None: 

144 root = None # if parent is None else parent._root 

145 

146 super().__init__(root, size) 

147 

148 self._parent = parent 

149 self._name = name 

150 self._linkSources = [] 

151 

152 @property 

153 def Parent(self) -> _ParentType: 

154 return self._parent 

155 

156 @Parent.setter 

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

158 self._parent = value 

159 

160 if value._root is not None: 

161 self._root = value._root 

162 

163 @readonly 

164 def Name(self) -> str: 

165 """ 

166 Read-only property to access the elements name. 

167 

168 :returns: Element name. 

169 """ 

170 return self._name 

171 

172 @readonly 

173 def Path(self) -> Path: 

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

175 

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

177 self._linkSources.append(source) 

178 

179 

180@export 

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

182 """ 

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

184 

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

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

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

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

189 

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

191 

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

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

194 """ 

195 

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

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

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

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

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

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

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

203 

204 def __init__( 

205 self, 

206 name: str, 

207 collectSubdirectories: bool = False, 

208 parent: Nullable["Directory"] = None 

209 ) -> None: 

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

211 

212 self._path = None 

213 self._subdirectories = {} 

214 self._files = {} 

215 self._symbolicLinks = {} 

216 self._collapsed = False 

217 self._scanDuration = None 

218 self._aggregateDuration = None 

219 

220 if parent is not None: 

221 parent._subdirectories[name] = self 

222 

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

224 self._root = parent._root 

225 

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

227 self._collectSubdirectories() 

228 

229 def _collectSubdirectories(self) -> None: 

230 with Stopwatch() as sw1: 

231 self._scanSubdirectories() 

232 

233 with Stopwatch() as sw2: 

234 self._aggregateSizes() 

235 

236 self._scanDuration = sw1.Duration 

237 self._aggregateDuration = sw2.Duration 

238 

239 def _scanSubdirectories(self) -> None: 

240 try: 

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

242 except PermissionError as ex: 

243 return 

244 

245 for dirEntry in items: 

246 if dirEntry.is_dir(follow_symlinks=False): 

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

248 elif dirEntry.is_file(follow_symlinks=False): 

249 id = dirEntry.inode() 

250 if id in self._root._ids: 

251 file = self._root._ids[id] 

252 

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

254 else: 

255 s = dirEntry.stat(follow_symlinks=False) 

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

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

258 

259 self._root._ids[id] = file 

260 elif dirEntry.is_symlink(): 

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

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

263 else: 

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

265 

266 def _connectSymbolicLinks(self) -> None: 

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

268 dir._connectSymbolicLinks() 

269 

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

271 if link._target.is_absolute(): 

272 pass 

273 else: 

274 target = self 

275 for elem in link._target.parts: 

276 if elem == ".": 

277 continue 

278 elif elem == "..": 

279 target = target._parent 

280 continue 

281 

282 try: 

283 target = target._subdirectories[elem] 

284 continue 

285 except KeyError: 

286 pass 

287 

288 try: 

289 target = target._files[elem] 

290 continue 

291 except KeyError: 

292 pass 

293 

294 try: 

295 target = target._symbolicLinks[elem] 

296 continue 

297 except KeyError: 

298 pass 

299 

300 target.AddLinkSources(link) 

301 

302 def _aggregateSizes(self) -> None: 

303 self._size = ( 

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

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

306 ) 

307 

308 @Element.Root.setter 

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

310 Element.Root.fset(self, value) 

311 

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

313 subdir.Root = value 

314 

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

316 file.Root = value 

317 

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

319 link.Root = value 

320 

321 @Element.Parent.setter 

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

323 Element.Parent.fset(self, value) 

324 

325 value._subdirectories[self._name] = self 

326 

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

328 self.Root = value 

329 

330 @readonly 

331 def Count(self) -> int: 

332 """ 

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

334 

335 :returns: Number of files plus subdirectories. 

336 """ 

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

338 

339 @readonly 

340 def FileCount(self) -> int: 

341 """ 

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

343 

344 .. hint:: 

345 

346 Files include regular files and symbolic links. 

347 

348 :returns: Number of files. 

349 """ 

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

351 

352 @readonly 

353 def RegularFileCount(self) -> int: 

354 """ 

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

356 

357 :returns: Number of regular files. 

358 """ 

359 return len(self._files) 

360 

361 @readonly 

362 def SymbolicLinkCount(self) -> int: 

363 """ 

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

365 

366 :returns: Number of symbolic links. 

367 """ 

368 return len(self._symbolicLinks) 

369 

370 @readonly 

371 def SubdirectoryCount(self) -> int: 

372 """ 

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

374 

375 :returns: Number of subdirectories. 

376 """ 

377 return len(self._subdirectories) 

378 

379 @readonly 

380 def TotalFileCount(self) -> int: 

381 """ 

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

383 

384 .. hint:: 

385 

386 Files include regular files and symbolic links. 

387 

388 :returns: Total number of files. 

389 """ 

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

391 

392 @readonly 

393 def TotalRegularFileCount(self) -> int: 

394 """ 

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

396 

397 :returns: Total number of regular files. 

398 """ 

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

400 

401 @readonly 

402 def TotalSymbolicLinkCount(self) -> int: 

403 """ 

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

405 

406 :returns: Total number of symbolic links. 

407 """ 

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

409 

410 @readonly 

411 def TotalSubdirectoryCount(self) -> int: 

412 """ 

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

414 

415 :returns: Total number of subdirectories. 

416 """ 

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

418 

419 @readonly 

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

421 """ 

422 Iterate all direct subdirectories of the directory. 

423 

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

425 """ 

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

427 

428 @readonly 

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

430 """ 

431 Iterate all direct files of the directory. 

432 

433 .. hint:: 

434 

435 Files include regular files and symbolic links. 

436 

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

438 """ 

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

440 

441 @readonly 

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

443 """ 

444 Iterate all direct regular files of the directory. 

445 

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

447 """ 

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

449 

450 @readonly 

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

452 """ 

453 Iterate all direct symbolic links of the directory. 

454 

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

456 """ 

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

458 

459 @readonly 

460 def Path(self) -> Path: 

461 """ 

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

463 

464 :returns: Path to the directory. 

465 :raises FilesystemException: If no parent is set. 

466 """ 

467 if self._path is not None: 

468 return self._path 

469 

470 if self._parent is None: 

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

472 

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

474 return self._path 

475 

476 @readonly 

477 def ScanDuration(self) -> float: 

478 """ 

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

480 

481 :returns: The scan duration in seconds. 

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

483 """ 

484 if self._scanDuration is None: 

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

486 

487 return self._scanDuration 

488 

489 @readonly 

490 def AggregateDuration(self) -> float: 

491 """ 

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

493 

494 :returns: The aggregation duration in seconds. 

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

496 """ 

497 if self._scanDuration is None: 

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

499 

500 return self._aggregateDuration 

501 

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

503 """ 

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

505 

506 .. hint:: 

507 

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

509 There is no rescan or repeated aggregation needed. 

510 

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

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

513 """ 

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

515 dir._size = self._size 

516 

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

518 subdir.Copy(dir) 

519 

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

521 file.Copy(dir) 

522 

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

524 link.Copy(dir) 

525 

526 return dir 

527 

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

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

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

531 if func(self): 

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

533 self._collapsed = True 

534 self._subdirectories.clear() 

535 self._files.clear() 

536 self._symbolicLinks.clear() 

537 

538 return True 

539 else: 

540 return False 

541 

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

543 collapsible = True 

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

545 result = subdir.Collapse(func) 

546 collapsible = collapsible and result 

547 

548 if collapsible: 

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

550 self._collapsed = True 

551 self._subdirectories.clear() 

552 self._files.clear() 

553 self._symbolicLinks.clear() 

554 

555 return True 

556 else: 

557 return False 

558 

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

560 """ 

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

562 

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

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

565 

566 ``kind`` 

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

568 ``size`` 

569 The directory's aggregated size. 

570 

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

572 :returns: A tree node representing this directory. 

573 """ 

574 if format is None: 

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

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

577 

578 directoryNode = Node( 

579 value=self, 

580 keyValuePairs={ 

581 "kind": NodeKind.File, 

582 "size": self._size 

583 }, 

584 format=format 

585 ) 

586 directoryNode.AddChildren( 

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

588 ) 

589 

590 return directoryNode 

591 

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

593 """ 

594 Compare two Directory instances for equality. 

595 

596 :param other: Parameter to compare against. 

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

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

599 """ 

600 if not isinstance(other, Directory): 

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

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

603 raise ex 

604 

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

606 return False 

607 

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

609 return False 

610 

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

612 return False 

613 

614 return True 

615 

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

617 """ 

618 Compare two Directory instances for inequality. 

619 

620 :param other: Parameter to compare against. 

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

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

623 """ 

624 return not self.__eq__(other) 

625 

626 def __repr__(self) -> str: 

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

628 

629 def __str__(self) -> str: 

630 return self._name 

631 

632 

633@export 

634class Filename(Element[Directory]): 

635 _file: Nullable["File"] 

636 

637 def __init__( 

638 self, 

639 name: str, 

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

641 parent: Nullable[Directory] = None 

642 ) -> None: 

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

644 

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

646 self._file = None 

647 else: 

648 self._file = file 

649 file._parents.append(self) 

650 

651 if parent is not None: 

652 parent._files[name] = self 

653 

654 if parent._root is not None: 

655 self._root = parent._root 

656 

657 @Element.Root.setter 

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

659 self._root = value 

660 

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

662 self._file._root = value 

663 

664 @Element.Parent.setter 

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

666 Element.Parent.fset(self, value) 

667 

668 value._files[self._name] = self 

669 

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

671 self.Root = value 

672 

673 @readonly 

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

675 return self._file 

676 

677 @readonly 

678 def Size(self) -> int: 

679 if self._file is None: 

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

681 

682 return self._file._size 

683 

684 @readonly 

685 def Path(self) -> Path: 

686 if self._parent is None: 

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

688 

689 return self._parent.Path / self._name 

690 

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

692 fileID = self._file._id 

693 

694 if fileID in parent._root._ids: 

695 file = parent._root._ids[fileID] 

696 else: 

697 fileSize = self._file._size 

698 file = File(fileID, fileSize) 

699 

700 parent._root._ids[fileID] = file 

701 

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

703 

704 def ToTree(self) -> Node: 

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

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

707 

708 fileNode = Node( 

709 value=self, 

710 keyValuePairs={ 

711 "kind": NodeKind.File, 

712 "size": self._size 

713 }, 

714 format=format 

715 ) 

716 

717 return fileNode 

718 

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

720 """ 

721 Compare two Filename instances for equality. 

722 

723 :param other: Parameter to compare against. 

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

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

726 """ 

727 if not isinstance(other, Filename): 

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

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

730 raise ex 

731 

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

733 

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

735 """ 

736 Compare two Filename instances for inequality. 

737 

738 :param other: Parameter to compare against. 

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

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

741 """ 

742 if not isinstance(other, Filename): 

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

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

745 raise ex 

746 

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

748 

749 def __repr__(self) -> str: 

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

751 

752 def __str__(self) -> str: 

753 return self._name 

754 

755 

756@export 

757class SymbolicLink(Element[Directory]): 

758 _target: Path 

759 

760 def __init__( 

761 self, 

762 name: str, 

763 target: Path, 

764 parent: Nullable[Directory] 

765 ) -> None: 

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

767 

768 self._target = target 

769 

770 if parent is not None: 

771 parent._symbolicLinks[name] = self 

772 

773 if parent._root is not None: 

774 self._root = parent._root 

775 

776 @readonly 

777 def Path(self) -> Path: 

778 return self._parent.Path / self._name 

779 

780 @readonly 

781 def Target(self) -> Path: 

782 return self._target 

783 

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

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

786 

787 def ToTree(self) -> Node: 

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

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

790 

791 symbolicLinkNode = Node( 

792 value=self, 

793 keyValuePairs={ 

794 "kind": NodeKind.SymbolicLink, 

795 "size": self._size 

796 }, 

797 format=format 

798 ) 

799 

800 return symbolicLinkNode 

801 

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

803 """ 

804 Compare two SymbolicLink instances for equality. 

805 

806 :param other: Parameter to compare against. 

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

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

809 """ 

810 if not isinstance(other, SymbolicLink): 

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

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

813 raise ex 

814 

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

816 

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

818 """ 

819 Compare two SymbolicLink instances for inequality. 

820 

821 :param other: Parameter to compare against. 

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

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

824 """ 

825 if not isinstance(other, SymbolicLink): 

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

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

828 raise ex 

829 

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

831 

832 def __repr__(self) -> str: 

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

834 

835 def __str__(self) -> str: 

836 return self._name 

837 

838 

839@export 

840class Root(Directory): 

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

842 

843 def __init__( 

844 self, 

845 rootDirectory: Path, 

846 collectSubdirectories: bool = True 

847 ) -> None: 

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

849 raise ValueError(f"Parameter 'path' is None.") 

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

851 raise TypeError(f"Parameter 'path' is not of type Path.") 

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

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

854 

855 self._ids = {} 

856 

857 super().__init__(rootDirectory.name) 

858 self._root = self 

859 self._path = rootDirectory 

860 

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

862 self._collectSubdirectories() 

863 self._connectSymbolicLinks() 

864 

865 @readonly 

866 def TotalHardLinkCount(self) -> int: 

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

868 

869 @readonly 

870 def TotalHardLinkCount2(self) -> int: 

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

872 

873 @readonly 

874 def TotalHardLinkCount3(self) -> int: 

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

876 

877 @readonly 

878 def Size2(self) -> int: 

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

880 

881 @readonly 

882 def Size3(self) -> int: 

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

884 

885 @readonly 

886 def TotalUniqueFileCount(self) -> int: 

887 return len(self._ids) 

888 

889 @readonly 

890 def Path(self) -> Path: 

891 """ 

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

893 

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

895 """ 

896 return self._path 

897 

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

899 """ 

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

901 

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

903 

904 .. hint:: 

905 

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

907 There is no rescan or repeated aggregation needed. 

908 

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

910 """ 

911 with Stopwatch() as sw: 

912 root = Root(self._path, False) 

913 root._size = self._size 

914 

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

916 subdir.Copy(root) 

917 

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

919 file.Copy(root) 

920 

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

922 link.Copy(root) 

923 

924 root._scanDuration = sw.Duration 

925 root._aggregateDuration = 0.0 

926 

927 return root 

928 

929 def __repr__(self) -> str: 

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

931 

932 def __str__(self) -> str: 

933 return self._name 

934 

935 

936@export 

937class File(Base): 

938 _id: int 

939 _parents: List[Filename] 

940 

941 def __init__( 

942 self, 

943 id: int, 

944 size: int, 

945 parent: Nullable[Filename] = None 

946 ) -> None: 

947 self._id = id 

948 if parent is None: 

949 super().__init__(None, size) 

950 self._parents = [] 

951 else: 

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

953 self._parents = [parent] 

954 parent._file = self 

955 

956 @readonly 

957 def ID(self) -> int: 

958 """ 

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

960 

961 :returns: Unique file object identifier. 

962 """ 

963 return self._id 

964 

965 @readonly 

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

967 """ 

968 Read-only property to access the list of filenames using the file object. 

969 

970 .. hint:: 

971 

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

973 

974 :returns: List of filenames for the file object. 

975 """ 

976 return self._parents 

977 

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

979 if file._file is not None: 979 ↛ 980line 979 didn't jump to line 980 because the condition on line 979 was never true

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

981 

982 self._parents.append(file) 

983 file._file = self 

984 

985 if file._root is not None: 

986 self._root = file._root