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

458 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-25 22:22 +0000

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 :def:`hardlinks <hardlink>`. 

189 

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

191 

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

193 aggregation is provided via :attr:`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 if version_info >= (3, 11): # pragma: no cover 

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

604 raise ex 

605 

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

607 return False 

608 

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

610 return False 

611 

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

613 return False 

614 

615 return True 

616 

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

618 """ 

619 Compare two Directory instances for inequality. 

620 

621 :param other: Parameter to compare against. 

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

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

624 """ 

625 return not self.__eq__(other) 

626 

627 def __repr__(self) -> str: 

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

629 

630 def __str__(self) -> str: 

631 return self._name 

632 

633 

634@export 

635class Filename(Element[Directory]): 

636 _file: Nullable["File"] 

637 

638 def __init__( 

639 self, 

640 name: str, 

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

642 parent: Nullable[Directory] = None 

643 ) -> None: 

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

645 

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

647 self._file = None 

648 else: 

649 self._file = file 

650 file._parents.append(self) 

651 

652 if parent is not None: 

653 parent._files[name] = self 

654 

655 if parent._root is not None: 

656 self._root = parent._root 

657 

658 @Element.Root.setter 

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

660 self._root = value 

661 

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

663 self._file._root = value 

664 

665 @Element.Parent.setter 

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

667 Element.Parent.fset(self, value) 

668 

669 value._files[self._name] = self 

670 

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

672 self.Root = value 

673 

674 @readonly 

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

676 return self._file 

677 

678 @readonly 

679 def Size(self) -> int: 

680 if self._file is None: 

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

682 

683 return self._file._size 

684 

685 @readonly 

686 def Path(self) -> Path: 

687 if self._parent is None: 

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

689 

690 return self._parent.Path / self._name 

691 

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

693 fileID = self._file._id 

694 

695 if fileID in parent._root._ids: 

696 file = parent._root._ids[fileID] 

697 else: 

698 fileSize = self._file._size 

699 file = File(fileID, fileSize) 

700 

701 parent._root._ids[fileID] = file 

702 

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

704 

705 def ToTree(self) -> Node: 

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

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

708 

709 fileNode = Node( 

710 value=self, 

711 keyValuePairs={ 

712 "kind": NodeKind.File, 

713 "size": self._size 

714 }, 

715 format=format 

716 ) 

717 

718 return fileNode 

719 

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

721 """ 

722 Compare two Filename instances for equality. 

723 

724 :param other: Parameter to compare against. 

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

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

727 """ 

728 if not isinstance(other, Filename): 

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

730 if version_info >= (3, 11): # pragma: no cover 

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

732 raise ex 

733 

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

735 

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

737 """ 

738 Compare two Filename instances for inequality. 

739 

740 :param other: Parameter to compare against. 

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

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

743 """ 

744 if not isinstance(other, Filename): 

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

746 if version_info >= (3, 11): # pragma: no cover 

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

748 raise ex 

749 

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

751 

752 def __repr__(self) -> str: 

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

754 

755 def __str__(self) -> str: 

756 return self._name 

757 

758 

759@export 

760class SymbolicLink(Element[Directory]): 

761 _target: Path 

762 

763 def __init__( 

764 self, 

765 name: str, 

766 target: Path, 

767 parent: Nullable[Directory] 

768 ) -> None: 

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

770 

771 self._target = target 

772 

773 if parent is not None: 

774 parent._symbolicLinks[name] = self 

775 

776 if parent._root is not None: 

777 self._root = parent._root 

778 

779 @readonly 

780 def Path(self) -> Path: 

781 return self._parent.Path / self._name 

782 

783 @readonly 

784 def Target(self) -> Path: 

785 return self._target 

786 

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

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

789 

790 def ToTree(self) -> Node: 

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

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

793 

794 symbolicLinkNode = Node( 

795 value=self, 

796 keyValuePairs={ 

797 "kind": NodeKind.SymbolicLink, 

798 "size": self._size 

799 }, 

800 format=format 

801 ) 

802 

803 return symbolicLinkNode 

804 

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

806 """ 

807 Compare two SymbolicLink instances for equality. 

808 

809 :param other: Parameter to compare against. 

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

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

812 """ 

813 if not isinstance(other, SymbolicLink): 

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

815 if version_info >= (3, 11): # pragma: no cover 

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

817 raise ex 

818 

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

820 

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

822 """ 

823 Compare two SymbolicLink instances for inequality. 

824 

825 :param other: Parameter to compare against. 

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

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

828 """ 

829 if not isinstance(other, SymbolicLink): 

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

831 if version_info >= (3, 11): # pragma: no cover 

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

833 raise ex 

834 

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

836 

837 def __repr__(self) -> str: 

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

839 

840 def __str__(self) -> str: 

841 return self._name 

842 

843 

844@export 

845class Root(Directory): 

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

847 

848 def __init__( 

849 self, 

850 rootDirectory: Path, 

851 collectSubdirectories: bool = True 

852 ) -> None: 

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

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

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

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

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

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

859 

860 self._ids = {} 

861 

862 super().__init__(rootDirectory.name) 

863 self._root = self 

864 self._path = rootDirectory 

865 

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

867 self._collectSubdirectories() 

868 self._connectSymbolicLinks() 

869 

870 @readonly 

871 def TotalHardLinkCount(self) -> int: 

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

873 

874 @readonly 

875 def TotalHardLinkCount2(self) -> int: 

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

877 

878 @readonly 

879 def TotalHardLinkCount3(self) -> int: 

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

881 

882 @readonly 

883 def Size2(self) -> int: 

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

885 

886 @readonly 

887 def Size3(self) -> int: 

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

889 

890 @readonly 

891 def TotalUniqueFileCount(self) -> int: 

892 return len(self._ids) 

893 

894 @readonly 

895 def Path(self) -> Path: 

896 """ 

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

898 

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

900 """ 

901 return self._path 

902 

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

904 """ 

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

906 

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

908 

909 .. hint:: 

910 

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

912 There is no rescan or repeated aggregation needed. 

913 

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

915 """ 

916 with Stopwatch() as sw: 

917 root = Root(self._path, False) 

918 root._size = self._size 

919 

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

921 subdir.Copy(root) 

922 

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

924 file.Copy(root) 

925 

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

927 link.Copy(root) 

928 

929 root._scanDuration = sw.Duration 

930 root._aggregateDuration = 0.0 

931 

932 return root 

933 

934 def __repr__(self) -> str: 

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

936 

937 def __str__(self) -> str: 

938 return self._name 

939 

940 

941@export 

942class File(Base): 

943 _id: int 

944 _parents: List[Filename] 

945 

946 def __init__( 

947 self, 

948 id: int, 

949 size: int, 

950 parent: Nullable[Filename] = None 

951 ) -> None: 

952 self._id = id 

953 if parent is None: 

954 super().__init__(None, size) 

955 self._parents = [] 

956 else: 

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

958 self._parents = [parent] 

959 parent._file = self 

960 

961 @readonly 

962 def ID(self) -> int: 

963 """ 

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

965 

966 :returns: Unique file object identifier. 

967 """ 

968 return self._id 

969 

970 @readonly 

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

972 """ 

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

974 

975 .. hint:: 

976 

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

978 

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

980 """ 

981 return self._parents 

982 

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

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

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

986 

987 self._parents.append(file) 

988 file._file = self 

989 

990 if file._root is not None: 

991 self._root = file._root