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

606 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-11 23:59 +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 

38.. seealso:: 

39 

40 :mod:`pyTooling.Filesystem.Docker` 

41 |rarr| Slicing a scanned filesystem into Docker image layers. 

42 :mod:`pyTooling.Tree` 

43 |rarr| The tree data structure a filesystem scope is converted to. 

44 :mod:`pyTooling.Stopwatch` 

45 |rarr| The stopwatch measuring how long a scan took. 

46""" 

47from __future__ import annotations 

48 

49from enum import Enum 

50from itertools import chain 

51from os import scandir, readlink 

52from pathlib import Path 

53from typing import Optional as Nullable, Generic, Generator, TypeVar, Any, Callable, Union 

54from typing import Iterator, cast 

55 

56from pyTooling.Decorators import readonly, export 

57from pyTooling.Exceptions import ToolingException 

58from pyTooling.MetaClasses import ExtendedType 

59from pyTooling.Common import getFullyQualifiedName, zipdicts 

60from pyTooling.Warning import WarningCollector, Warning 

61from pyTooling.Stopwatch import Stopwatch 

62from pyTooling.Tree import Node 

63 

64 

65__all__ = ["_ParentType"] 

66 

67 

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

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

70 

71 

72@export 

73class FilesystemException(ToolingException): 

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

75 

76 

77@export 

78class PermissionWarning(Warning): 

79 """ 

80 Warning emitted when a directory or file couldn't be read while scanning a filesystem. 

81 

82 The scan continues, so the collected statistics are incomplete by exactly the path this warning carries. 

83 """ 

84 _path: Path #: Path that couldn't be read. 

85 

86 def __init__(self, path: Path, *args: Any) -> None: 

87 """ 

88 Initialize a permission warning for the path that couldn't be read. 

89 

90 :param path: The path that raised a :exc:`PermissionError`. 

91 :param args: Positional parameters forwarded to the base-class. 

92 """ 

93 super().__init__(*args) 

94 self._path = path 

95 

96 @readonly 

97 def Path(self) -> Path: 

98 """ 

99 Read-only property to access the path that couldn't be read (:attr:`_path`). 

100 

101 :returns: The path that raised a :exc:`PermissionError`. 

102 """ 

103 return self._path 

104 

105 

106@export 

107class NodeKind(Enum): 

108 """ 

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

110 

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

112 """ 

113 Directory = 0 #: Node represents a directory. 

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

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

116 

117 

118@export 

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

120 """ 

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

122 

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

124 """ 

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

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

127 

128 def __init__( 

129 self, 

130 size: Nullable[int], 

131 root: Nullable[Root] 

132 ) -> None: 

133 """ 

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

135 

136 :param size: Optional, size of the element. 

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

138 :raises TypeError: If parameter 'size' is not of type integer. 

139 :raises TypeError: If parameter 'root' is not of type :class:`Root`. 

140 """ 

141 if size is not None and not isinstance(size, int): 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true

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

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

144 raise ex 

145 

146 if root is not None and not isinstance(root, Root): 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true

147 ex = TypeError("Parameter 'root' is not of type 'Root'.") 

148 ex.add_note(f"Got type '{getFullyQualifiedName(root)}'.") 

149 raise ex 

150 

151 self._size = size 

152 self._root = root 

153 

154 @property 

155 def Root(self) -> Nullable[Root]: 

156 """ 

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

158 

159 :returns: Root of the filesystem statistics scope. 

160 :raises ValueError: If ``None`` is assigned. 

161 :raises TypeError: If an assigned value is not of type :class:`Root`. 

162 """ 

163 return self._root 

164 

165 @Root.setter 

166 def Root(self, value: Root) -> None: 

167 if value is None: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true

168 raise ValueError(f"Parameter 'value' is None.") 

169 elif not isinstance(value, Root): 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true

170 ex = TypeError("Parameter 'value' is not of type 'Root'.") 

171 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

172 raise ex 

173 

174 self._root = value 

175 

176 @readonly 

177 def Size(self) -> int: 

178 """ 

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

180 

181 :returns: Size in Bytes. 

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

183 """ 

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

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

186 

187 return self._size 

188 

189 # FIXME: @abstractmethod 

190 def ToTree(self) -> Node: 

191 """ 

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

193 

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

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

196 

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

198 """ 

199 raise NotImplementedError() 

200 

201 

202@export 

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

204 """ 

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

206 

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

208 

209 .. hint:: 

210 

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

212 """ 

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

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

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

216 

217 def __init__( 

218 self, 

219 name: str, 

220 size: Nullable[int] = None, 

221 parent: Nullable[_ParentType] = None 

222 ) -> None: 

223 """ 

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

225 

226 :param name: Name of the element. 

227 :param size: Optional, size of the element. 

228 :param parent: Optional, parent reference. 

229 :raises ValueError: If parameter 'name' is None. 

230 :raises TypeError: If parameter 'parent' is not of type :class:`Directory`. 

231 """ 

232 if name is None: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true

233 raise ValueError(f"Parameter 'name' is None.") 

234 elif not isinstance(name, str): 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 ex = TypeError("Parameter 'name' is not of type 'str'.") 

236 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") 

237 raise ex 

238 

239 self._name = name 

240 

241 if parent is None: 

242 super().__init__(size, None) 

243 self._parent = None 

244 else: 

245 if not isinstance(parent, Directory): 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true

246 ex = TypeError("Parameter 'parent' is not of type 'Directory'.") 

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

248 raise ex 

249 

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

251 self._parent = parent 

252 

253 self._linkSources = [] 

254 

255 @property 

256 def Parent(self) -> _ParentType: 

257 """ 

258 Property to access the element's parent. 

259 

260 :returns: Parent element. 

261 :raises ValueError: If ``None`` is assigned. 

262 :raises TypeError: If an assigned value is not of type :class:`Directory`. 

263 """ 

264 return self._parent 

265 

266 @Parent.setter 

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

268 if value is None: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 raise ValueError(f"Parameter 'value' is None.") 

270 elif not isinstance(value, Directory): 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 ex = TypeError("Parameter 'value' is not of type 'Directory'.") 

272 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

273 raise ex 

274 

275 self._parent = value 

276 

277 if value._root is not None: 

278 self._root = value._root 

279 

280 @readonly 

281 def Name(self) -> str: 

282 """ 

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

284 

285 :returns: Element name. 

286 """ 

287 return self._name 

288 

289 @readonly 

290 def Path(self) -> Path: 

291 """ 

292 Read-only property to access the element's path. 

293 

294 :returns: Path of the element. 

295 """ 

296 raise NotImplementedError(f"Property 'Path' is abstract.") 

297 

298 @readonly 

299 def LinkSources(self) -> list[SymbolicLink]: 

300 """ 

301 Read-only property to access the symbolic links pointing to this element (:attr:`_linkSources`). 

302 

303 :returns: List of symbolic links targeting this element. 

304 """ 

305 return self._linkSources 

306 

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

308 """ 

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

310 

311 :param source: The referenced symbolic link. 

312 :raises TypeError: If parameter 'source' is not of type :class:`SymbolicLink`. 

313 """ 

314 if not isinstance(source, SymbolicLink): 

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

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

317 raise ex 

318 

319 source._isConnected = True 

320 source._isBroken = False 

321 source._isOutOfRange = False 

322 self._linkSources.append(source) 

323 

324 

325@export 

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

327 """ 

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

329 

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

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

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

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

334 

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

336 

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

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

339 """ 

340 

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

342 _subdirectories: dict[str, Directory] #: Dictionary containing name-:class:`Directory` pairs. 

343 _files: dict[str, Filename] #: Dictionary containing name-:class:`Filename` pairs. 

344 _symbolicLinks: dict[str, SymbolicLink] #: Dictionary containing name-:class:`SymbolicLink` pairs. 

345 _filesSize: int #: Aggregated size of all direct files. 

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

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

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

349 

350 def __init__( 

351 self, 

352 name: str, 

353 collectSubdirectories: bool = False, 

354 parent: Nullable[Directory] = None 

355 ) -> None: 

356 """ 

357 Initialize the directory with name and parent reference. 

358 

359 :param name: Name of the element. 

360 :param collectSubdirectories: Optional, if ``True``, collect subdirectory statistics. 

361 :param parent: Optional, parent reference. 

362 """ 

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

364 

365 self._path = None 

366 self._subdirectories = {} 

367 self._files = {} 

368 self._symbolicLinks = {} 

369 self._filesSize = 0 

370 self._collapsed = False 

371 self._scanDuration = None 

372 self._aggregateDuration = None 

373 

374 if parent is not None: 

375 parent._subdirectories[name] = self 

376 

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

378 self._root = parent._root 

379 

380 if collectSubdirectories: 

381 self.CollectSubdirectories() 

382 

383 def CollectSubdirectories(self) -> None: 

384 """ 

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

386 """ 

387 self.ScanSubdirectories() 

388 self.AggregateSizes() 

389 

390 def ScanSubdirectories(self) -> None: 

391 """ 

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

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

394 

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

396 storage object is assumed. 

397 

398 A directory that can't be read is reported as a :class:`PermissionWarning` and skipped, so the scan continues and 

399 the collected statistics are incomplete by exactly that path. 

400 

401 :raises FilesystemException: If this directory isn't attached to a :class:`Root`, which owns the ID table. 

402 :raises FilesystemException: If the directory contains an element that is neither a directory, a file nor a 

403 symbolic link. 

404 """ 

405 if (root := self._root) is None: 

406 raise FilesystemException(f"Directory '{self._name}' is not attached to a filesystem root.") 

407 

408 with Stopwatch() as sw1: 

409 try: 

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

411 except PermissionError as ex: 

412 return WarningCollector.Raise(PermissionWarning(self.Path), ex) 

413 

414 for dirEntry in items: 

415 if dirEntry.is_dir(follow_symlinks=False): 

416 _ = Directory(dirEntry.name, collectSubdirectories=True, parent=self) 

417 elif dirEntry.is_file(follow_symlinks=False): 417 ↛ 429line 417 didn't jump to line 429 because the condition on line 417 was always true

418 id = dirEntry.inode() 

419 if id in root._ids: 419 ↛ 420line 419 didn't jump to line 420 because the condition on line 419 was never true

420 file = root._ids[id] 

421 

422 _ = Filename(dirEntry.name, file=file, parent=self) 

423 else: 

424 s = dirEntry.stat(follow_symlinks=False) 

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

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

427 

428 root._ids[id] = file 

429 elif dirEntry.is_symlink(): 

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

431 _ = SymbolicLink(dirEntry.name, target, parent=self) 

432 else: 

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

434 

435 self._scanDuration = sw1.Duration 

436 

437 def ResolveSymbolicLinks(self) -> None: 

438 """ 

439 Resolve the symbolic links of this directory and of every directory below it. 

440 

441 A link whose target lies inside the scanned tree is connected to that element; a target that doesn't exist 

442 registers the link as broken, and a target outside the scanned tree registers it as unconnected. 

443 """ 

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

445 dir.ResolveSymbolicLinks() 

446 

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

448 if link._target.is_absolute(): 

449 # todo: resolve path and check if target is in range, otherwise add to out-of-range list 

450 pass 

451 else: 

452 target = self 

453 for elem in link._target.parts: 

454 if elem == ".": 

455 continue 

456 elif elem == "..": 

457 if (target := target._parent) is None: 

458 self._root.RegisterUnconnectedSymbolicLink(link) 

459 break 

460 

461 continue 

462 

463 try: 

464 target = target._subdirectories[elem] 

465 continue 

466 except KeyError: 

467 pass 

468 

469 try: 

470 target = target._files[elem] 

471 continue 

472 except KeyError: 

473 pass 

474 

475 try: 

476 target = target._symbolicLinks[elem] 

477 continue 

478 except KeyError: 

479 self._root.RegisterBrokenSymbolicLink(link) 

480 break 

481 else: 

482 target.AddLinkSources(link) 

483 

484 def AggregateSizes(self) -> set[File]: 

485 """ 

486 Compute the aggregated size of this directory and of every directory below it. 

487 

488 A file is counted once, even when several filenames (hardlinks) refer to it, which is why the already counted 

489 files are returned and handed up the recursion. 

490 

491 :returns: The set of file objects counted in this subtree. 

492 """ 

493 with Stopwatch() as sw2: 

494 aggregatedFiles = set() 

495 

496 self._size = 0 

497 self._filesSize = 0 

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

499 aggregatedFiles |= dir.AggregateSizes() 

500 self._size += dir._size 

501 

502 for filename in self._files.values(): 

503 if (file := filename._file) not in aggregatedFiles: 503 ↛ 502line 503 didn't jump to line 502 because the condition on line 503 was always true

504 self._filesSize += file._size 

505 aggregatedFiles.add(file) 

506 

507 self._size += self._filesSize 

508 

509 self._aggregateDuration = sw2.Duration 

510 

511 return aggregatedFiles 

512 

513 @Element.Root.setter 

514 def Root(self, value: Root) -> None: 

515 Element.Root.fset(self, value) 

516 

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

518 subdir.Root = value 

519 

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

521 file.Root = value 

522 

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

524 link.Root = value 

525 

526 @Element.Parent.setter 

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

528 Element.Parent.fset(self, value) 

529 

530 value._subdirectories[self._name] = self 

531 

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

533 self.Root = value 

534 

535 @readonly 

536 def Count(self) -> int: 

537 """ 

538 Read-only property to return the number of elements in a directory. 

539 

540 :returns: Number of files plus subdirectories. 

541 """ 

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

543 

544 @readonly 

545 def FileCount(self) -> int: 

546 """ 

547 Read-only property to return the number of files in a directory. 

548 

549 .. hint:: 

550 

551 Files include regular files and symbolic links. 

552 

553 :returns: Number of files. 

554 """ 

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

556 

557 @readonly 

558 def RegularFileCount(self) -> int: 

559 """ 

560 Read-only property to return the number of regular files in a directory. 

561 

562 :returns: Number of regular files. 

563 """ 

564 return len(self._files) 

565 

566 @readonly 

567 def SymbolicLinkCount(self) -> int: 

568 """ 

569 Read-only property to return the number of symbolic links in a directory. 

570 

571 :returns: Number of symbolic links. 

572 """ 

573 return len(self._symbolicLinks) 

574 

575 @readonly 

576 def SubdirectoryCount(self) -> int: 

577 """ 

578 Read-only property to return the number of subdirectories in a directory. 

579 

580 :returns: Number of subdirectories. 

581 """ 

582 return len(self._subdirectories) 

583 

584 @readonly 

585 def TotalFileCount(self) -> int: 

586 """ 

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

588 

589 .. hint:: 

590 

591 Files include regular files and symbolic links. 

592 

593 :returns: Total number of files. 

594 """ 

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

596 

597 @readonly 

598 def TotalRegularFileCount(self) -> int: 

599 """ 

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

601 

602 :returns: Total number of regular files. 

603 """ 

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

605 

606 @readonly 

607 def TotalSymbolicLinkCount(self) -> int: 

608 """ 

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

610 

611 :returns: Total number of symbolic links. 

612 """ 

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

614 

615 @readonly 

616 def TotalSubdirectoryCount(self) -> int: 

617 """ 

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

619 

620 :returns: Total number of subdirectories. 

621 """ 

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

623 

624 @readonly 

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

626 """ 

627 Iterate all direct subdirectories of the directory. 

628 

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

630 """ 

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

632 

633 @readonly 

634 def Files(self) -> Generator[Filename | SymbolicLink, None, None]: 

635 """ 

636 Iterate all direct files of the directory. 

637 

638 .. hint:: 

639 

640 Files include regular files and symbolic links. 

641 

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

643 """ 

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

645 

646 @readonly 

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

648 """ 

649 Iterate all direct regular files of the directory. 

650 

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

652 """ 

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

654 

655 @readonly 

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

657 """ 

658 Iterate all direct symbolic links of the directory. 

659 

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

661 """ 

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

663 

664 @readonly 

665 def Path(self) -> Path: 

666 """ 

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

668 

669 :returns: Path to the directory. 

670 :raises FilesystemException: If no parent is set. 

671 """ 

672 if self._path is not None: 

673 return self._path 

674 

675 if self._parent is None: 675 ↛ 676line 675 didn't jump to line 676 because the condition on line 675 was never true

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

677 

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

679 return self._path 

680 

681 @readonly 

682 def ScanDuration(self) -> float: 

683 """ 

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

685 

686 :returns: The scan duration in seconds. 

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

688 """ 

689 if self._scanDuration is None: 

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

691 

692 return self._scanDuration 

693 

694 @readonly 

695 def AggregateDuration(self) -> float: 

696 """ 

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

698 

699 :returns: The aggregation duration in seconds. 

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

701 """ 

702 if self._scanDuration is None: 

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

704 

705 return self._aggregateDuration 

706 

707 def __hash__(self) -> int: 

708 """ 

709 Compute a hash for this filesystem element based on its identity. 

710 

711 Two elements with the same name in different directories are different elements, so the hash is derived from the 

712 object's identity and not from its name. 

713 

714 :returns: Hash of this filesystem element. 

715 """ 

716 return hash(id(self)) 

717 

718 def IterateDirectories(self) -> Generator[Directory, None, None]: 

719 """ 

720 A generator to iterate all subdirectories below this directory in pre-order. 

721 

722 A parent directory is yielded before its children. 

723 

724 :returns: A generator to iterate all subdirectories below this directory. 

725 """ 

726 # pre-order 

727 for directory in self._subdirectories.values(): 

728 yield directory 

729 yield from directory.IterateDirectories() 

730 

731 def IterateFiles(self) -> Generator[Element, None, None]: 

732 """ 

733 A generator to iterate all files and symbolic links below this directory in post-order. 

734 

735 The elements of the subdirectories are yielded before this directory's own. 

736 

737 :returns: A generator to iterate all files and symbolic links below this directory. 

738 """ 

739 # post-order 

740 for directory in self._subdirectories.values(): 

741 yield from directory.IterateFiles() 

742 

743 yield from self._files.values() 

744 yield from self._symbolicLinks.values() 

745 

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

747 """ 

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

749 

750 .. hint:: 

751 

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

753 There is no rescan or repeated aggregation needed. 

754 

755 :param parent: Optional, the parent element of the copied directory. 

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

757 """ 

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

759 dir._size = self._size 

760 

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

762 subdir.Copy(dir) 

763 

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

765 file.Copy(dir) 

766 

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

768 link.Copy(dir) 

769 

770 return dir 

771 

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

773 """ 

774 Collapse this directory's subtree where the given predicate accepts it. 

775 

776 A directory is collapsed when it has no subdirectories left - or all of them collapsed - and the predicate 

777 accepts it. Collapsing discards the directory's elements, so only its aggregated numbers remain. 

778 

779 :param func: Predicate deciding whether a directory may be collapsed. 

780 :returns: ``True``, if this directory was collapsed. 

781 """ 

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

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

784 if func(self): 

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

786 self._collapsed = True 

787 self._subdirectories.clear() 

788 self._files.clear() 

789 self._symbolicLinks.clear() 

790 

791 return True 

792 else: 

793 return False 

794 

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

796 collapsible = True 

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

798 result = subdir.Collapse(func) 

799 collapsible = collapsible and result 

800 

801 if collapsible: 

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

803 self._collapsed = True 

804 self._subdirectories.clear() 

805 self._files.clear() 

806 self._symbolicLinks.clear() 

807 

808 return True 

809 else: 

810 return False 

811 

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

813 """ 

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

815 

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

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

818 

819 ``kind`` 

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

821 ``size`` 

822 The directory's aggregated size. 

823 

824 :param format: Optional, a user defined formatting function for tree nodes. 

825 :returns: A tree node representing this directory. 

826 """ 

827 if format is None: 

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

829 """ 

830 Nested function rendering a tree node as one line. 

831 

832 :param node: The tree node to render. 

833 :returns: The node's size in MiB, followed by its name. 

834 """ 

835 element = cast(Element[Any], node._value) # the node was created with this element as its value 

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

837 

838 directoryNode: Node[Any, Any, Any, Any] = Node( 

839 value=self, 

840 keyValuePairs={ 

841 "kind": NodeKind.File, 

842 "size": self._size 

843 }, 

844 format=format 

845 ) 

846 directoryNode.AddChildren( 

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

848 ) 

849 

850 return directoryNode 

851 

852 def __eq__(self, other: Any) -> bool: 

853 """ 

854 Compare two Directory instances for equality. 

855 

856 :param other: Parameter to compare against. 

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

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

859 """ 

860 if not isinstance(other, Directory): 

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

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

863 raise ex 

864 

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

866 return False 

867 

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

869 return False 

870 

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

872 return False 

873 

874 return True 

875 

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

877 """ 

878 Compare two Directory instances for inequality. 

879 

880 :param other: Parameter to compare against. 

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

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

883 """ 

884 return not self.__eq__(other) 

885 

886 def __repr__(self) -> str: 

887 """ 

888 Return a detailed string representation of this directory. 

889 

890 :returns: The directory's full path, prefixed by its kind. 

891 """ 

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

893 

894 def __str__(self) -> str: 

895 """ 

896 Return a string representation of this filesystem element. 

897 

898 :returns: The element's name, without any path. 

899 """ 

900 return self._name 

901 

902 

903@export 

904class Filename(Element[Directory]): 

905 """ 

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

907 

908 .. hint:: 

909 

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

911 object. 

912 """ 

913 _file: Nullable[File] #: The file this filename refers to; ``None`` until the filename is linked. 

914 

915 def __init__( 

916 self, 

917 name: str, 

918 file: Nullable[File] = None, 

919 parent: Nullable[Directory] = None 

920 ) -> None: 

921 """ 

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

923 

924 :param name: Name of the file. 

925 :param file: Optional, file (storage) object. 

926 :param parent: Optional, parent reference. 

927 :raises TypeError: If parameter 'file' is not of type :class:`File`. 

928 """ 

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

930 

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

932 self._file = None 

933 else: 

934 if not isinstance(file, File): 

935 ex = TypeError("Parameter 'file' is not of type 'File'.") 

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

937 raise ex 

938 

939 self._file = file 

940 file._parents.append(self) 

941 

942 if parent is not None: 

943 parent._files[name] = self 

944 

945 if parent._root is not None: 

946 self._root = parent._root 

947 

948 @Element.Root.setter 

949 def Root(self, value: Root) -> None: 

950 Element.Root.fset(self, value) 

951 

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

953 self._file.Root = value 

954 

955 @Element.Parent.setter 

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

957 Element.Parent.fset(self, value) 

958 

959 value._files[self._name] = self 

960 

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

962 self.Root = value 

963 

964 @readonly 

965 def File(self) -> Nullable[File]: 

966 """ 

967 Read-only property to access the file this filename is linked to (:attr:`_file`). 

968 

969 :returns: The linked file, or ``None`` if the filename isn't linked yet. 

970 """ 

971 return self._file 

972 

973 @readonly 

974 def Size(self) -> int: 

975 """ 

976 Read-only property to access the size of the linked file. 

977 

978 :returns: Size of the linked file in bytes. 

979 :raises ToolingException: If the filename isn't linked to a file object. 

980 """ 

981 if self._file is None: 

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

983 

984 return self._file._size 

985 

986 @readonly 

987 def Path(self) -> Path: 

988 """ 

989 Read-only property to return the filename's absolute path. 

990 

991 The path is computed from the parent directory's path and the filename. 

992 

993 :returns: Absolute path of the file. 

994 :raises ToolingException: If the filename has no parent object. 

995 """ 

996 if self._parent is None: 

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

998 

999 return self._parent.Path / self._name 

1000 

1001 def __hash__(self) -> int: 

1002 """ 

1003 Compute a hash for this filesystem element based on its identity. 

1004 

1005 Two elements with the same name in different directories are different elements, so the hash is derived from the 

1006 object's identity and not from its name. 

1007 

1008 :returns: Hash of this filesystem element. 

1009 """ 

1010 return hash(id(self)) 

1011 

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

1013 """ 

1014 Copy this filename into another filesystem statistics scope. 

1015 

1016 The file object behind the filename is copied only once per scope: a filename referring to a file that was 

1017 already copied - a hardlink - is connected to the existing copy. 

1018 

1019 :param parent: Optional, the directory in the target scope the copy is registered at. 

1020 :returns: The copied filename. 

1021 """ 

1022 fileID = self._file._id 

1023 

1024 if fileID in parent._root._ids: 

1025 file = parent._root._ids[fileID] 

1026 else: 

1027 fileSize = self._file._size 

1028 file = File(fileID, fileSize) 

1029 

1030 parent._root._ids[fileID] = file 

1031 

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

1033 

1034 def ToTree(self) -> Node: 

1035 """ 

1036 Convert this filename to a node of a :mod:`pyTooling.Tree`. 

1037 

1038 :returns: A tree node carrying this filename, its kind and its size. 

1039 """ 

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

1041 """ 

1042 Nested function rendering a tree node as one line. 

1043 

1044 :param node: The tree node to render. 

1045 :returns: The node's size in MiB, followed by its name. 

1046 """ 

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

1048 

1049 fileNode: Node[Any, Any, Any, Any] = Node( 

1050 value=self, 

1051 keyValuePairs={ 

1052 "kind": NodeKind.File, 

1053 "size": self._size 

1054 }, 

1055 format=format 

1056 ) 

1057 

1058 return fileNode 

1059 

1060 def __eq__(self, other: Any) -> bool: 

1061 """ 

1062 Compare two Filename instances for equality. 

1063 

1064 :param other: Parameter to compare against. 

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

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

1067 """ 

1068 if not isinstance(other, Filename): 

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

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

1071 raise ex 

1072 

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

1074 

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

1076 """ 

1077 Compare two Filename instances for inequality. 

1078 

1079 :param other: Parameter to compare against. 

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

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

1082 """ 

1083 if not isinstance(other, Filename): 

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

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

1086 raise ex 

1087 

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

1089 

1090 def __repr__(self) -> str: 

1091 """ 

1092 Return a detailed string representation of this filename. 

1093 

1094 :returns: The file's full path, prefixed by its kind. 

1095 """ 

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

1097 

1098 def __str__(self) -> str: 

1099 """ 

1100 Return a string representation of this filesystem element. 

1101 

1102 :returns: The element's name, without any path. 

1103 """ 

1104 return self._name 

1105 

1106 

1107@export 

1108class SymbolicLink(Element[Directory]): 

1109 """ 

1110 A symbolic link in the filesystem statistics scope. 

1111 

1112 After the scan, the link is resolved: it is either connected to an element of the scanned tree, broken (the target 

1113 doesn't exist), or out of range (the target lies outside the scanned tree). 

1114 """ 

1115 _target: Path #: Path the symbolic link points to. 

1116 _isConnected: bool #: ``True``, if the link target was resolved to an element of the scanned tree. 

1117 _isBroken: Nullable[bool] #: ``True``, if the link target doesn't exist; ``None`` until resolved. 

1118 _isOutOfRange: Nullable[bool] #: ``True``, if the link target lies outside the scanned tree; ``None`` until resolved. 

1119 

1120 def __init__( 

1121 self, 

1122 name: str, 

1123 target: Path, 

1124 parent: Nullable[Directory] 

1125 ) -> None: 

1126 """ 

1127 Initialize a symbolic link, which is registered at its parent directory. 

1128 

1129 The link is unresolved at first: :meth:`Root.ResolveSymbolicLinks` decides afterwards whether it is connected, 

1130 broken or out of range. 

1131 

1132 :param name: Name of the symbolic link. 

1133 :param target: Path the symbolic link points to. 

1134 :param parent: Optional, parent directory of the symbolic link. 

1135 :raises ValueError: If parameter 'target' is None. 

1136 :raises TypeError: If parameter 'target' is not of type :class:`~pathlib.Path`. 

1137 """ 

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

1139 

1140 if target is None: 

1141 raise ValueError(f"Parameter 'target' is None.") 

1142 elif not isinstance(target, Path): 

1143 ex = TypeError("Parameter 'target' is not of type 'Path'.") 

1144 ex.add_note(f"Got type '{getFullyQualifiedName(target)}'.") 

1145 raise ex 

1146 

1147 self._target = target 

1148 self._isConnected = False 

1149 self._isBroken = None 

1150 self._isOutOfRange = None 

1151 

1152 if parent is not None: 

1153 parent._symbolicLinks[name] = self 

1154 

1155 if parent._root is not None: 

1156 self._root = parent._root 

1157 

1158 @readonly 

1159 def Path(self) -> Path: 

1160 """ 

1161 Read-only property to return the symbolic link's path. 

1162 

1163 The path is computed from the parent directory's path and the link's name. 

1164 

1165 :returns: Path of the symbolic link. 

1166 """ 

1167 return self._parent.Path / self._name 

1168 

1169 @readonly 

1170 def Target(self) -> Path: 

1171 """ 

1172 Read-only property to access the path this symbolic link points to (:attr:`_target`). 

1173 

1174 :returns: Target path of the symbolic link. 

1175 """ 

1176 return self._target 

1177 

1178 @readonly 

1179 def IsConnected(self) -> bool: 

1180 """ 

1181 Check if the symbolic link was resolved to an element within the scanned filesystem (:attr:`_isConnected`). 

1182 

1183 :returns: ``True``, if the link's target was found and connected. 

1184 """ 

1185 return self._isConnected 

1186 

1187 @readonly 

1188 def IsBroken(self) -> Nullable[bool]: 

1189 """ 

1190 Check if the symbolic link points to a non-existing target (:attr:`_isBroken`). 

1191 

1192 :returns: ``True``, if the target doesn't exist. ``None``, if the link wasn't resolved yet. 

1193 """ 

1194 return self._isBroken 

1195 

1196 @readonly 

1197 def IsOutOfRange(self) -> Nullable[bool]: 

1198 """ 

1199 Check if the symbolic link points outside the scanned filesystem (:attr:`_isOutOfRange`). 

1200 

1201 :returns: ``True``, if the target lies outside the scanned root. ``None``, if the link wasn't resolved yet. 

1202 """ 

1203 return self._isOutOfRange 

1204 

1205 def __hash__(self) -> int: 

1206 """ 

1207 Compute a hash for this filesystem element based on its identity. 

1208 

1209 Two elements with the same name in different directories are different elements, so the hash is derived from the 

1210 object's identity and not from its name. 

1211 

1212 :returns: Hash of this filesystem element. 

1213 """ 

1214 return hash(id(self)) 

1215 

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

1217 """ 

1218 Copy this symbolic link into another filesystem statistics scope. 

1219 

1220 :param parent: Optional, the directory in the target scope the copy is registered at. 

1221 :returns: The copied symbolic link, unresolved. 

1222 """ 

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

1224 

1225 def ToTree(self) -> Node: 

1226 """ 

1227 Convert this symbolic link to a node of a :mod:`pyTooling.Tree`. 

1228 

1229 :returns: A tree node carrying this symbolic link, its kind and its size. 

1230 """ 

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

1232 """ 

1233 Nested function rendering a tree node as one line. 

1234 

1235 :param node: The tree node to render. 

1236 :returns: The node's size in MiB, followed by its name. 

1237 """ 

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

1239 

1240 symbolicLinkNode: Node[Any, Any, Any, Any] = Node( 

1241 value=self, 

1242 keyValuePairs={ 

1243 "kind": NodeKind.SymbolicLink, 

1244 "size": self._size 

1245 }, 

1246 format=format 

1247 ) 

1248 

1249 return symbolicLinkNode 

1250 

1251 def __eq__(self, other: Any) -> bool: 

1252 """ 

1253 Compare two SymbolicLink instances for equality. 

1254 

1255 :param other: Parameter to compare against. 

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

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

1258 """ 

1259 if not isinstance(other, SymbolicLink): 

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

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

1262 raise ex 

1263 

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

1265 

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

1267 """ 

1268 Compare two SymbolicLink instances for inequality. 

1269 

1270 :param other: Parameter to compare against. 

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

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

1273 """ 

1274 if not isinstance(other, SymbolicLink): 

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

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

1277 raise ex 

1278 

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

1280 

1281 def __repr__(self) -> str: 

1282 """ 

1283 Return a detailed string representation of this symbolic link. 

1284 

1285 :returns: The link's full path and the path it points to. 

1286 """ 

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

1288 

1289 def __str__(self) -> str: 

1290 """ 

1291 Return a string representation of this filesystem element. 

1292 

1293 :returns: The element's name, without any path. 

1294 """ 

1295 return self._name 

1296 

1297 

1298@export 

1299class Root(Directory): 

1300 """ 

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

1302 """ 

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

1304 _brokenSymbolicLinks: list[SymbolicLink] #: Broken symbolic links (target doesn't exist). 

1305 _unconnectedSymbolicLinks: list[SymbolicLink] #: Symbolic links which couldn't be connected to their target (out of scope). 

1306 

1307 def __init__( 

1308 self, 

1309 rootDirectory: Path, 

1310 collectSubdirectories: bool = True 

1311 ) -> None: 

1312 """ 

1313 Initialize a filesystem statistics scope for the given directory. 

1314 

1315 Unless ``collectSubdirectories`` is disabled, the whole tree is scanned and its symbolic links are resolved right 

1316 away, so the root is usable as soon as it exists. 

1317 

1318 :param rootDirectory: Directory to collect the statistics for. 

1319 :param collectSubdirectories: Optional, if ``True``, scan the tree and resolve its symbolic links immediately. 

1320 :raises ValueError: If parameter 'rootDirectory' is None. 

1321 :raises TypeError: If parameter 'rootDirectory' is not of type :class:`~pathlib.Path`. 

1322 :raises ToolingException: If the given path doesn't exist. 

1323 """ 

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

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

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

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

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

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

1330 

1331 self._ids = {} 

1332 self._brokenSymbolicLinks = [] 

1333 self._unconnectedSymbolicLinks = [] 

1334 

1335 super().__init__(rootDirectory.name) 

1336 self._root = self 

1337 self._path = rootDirectory 

1338 

1339 if collectSubdirectories: 

1340 self.CollectSubdirectories() 

1341 self.ResolveSymbolicLinks() 

1342 

1343 @readonly 

1344 def Path(self) -> Path: 

1345 """ 

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

1347 

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

1349 """ 

1350 return self._path 

1351 

1352 @readonly 

1353 def BrokenSymbolicLinks(self) -> list[SymbolicLink]: 

1354 """ 

1355 Read-only property to access all symbolic links with a non-existing target (:attr:`_brokenSymbolicLinks`). 

1356 

1357 :returns: List of broken symbolic links. 

1358 """ 

1359 return self._brokenSymbolicLinks 

1360 

1361 @readonly 

1362 def UnconnectedSymbolicLinks(self) -> list[SymbolicLink]: 

1363 """ 

1364 Read-only property to access all symbolic links that couldn't be resolved within the scanned filesystem (:attr:`_unconnectedSymbolicLinks`). 

1365 

1366 :returns: List of unconnected symbolic links. 

1367 """ 

1368 return self._unconnectedSymbolicLinks 

1369 

1370 @readonly 

1371 def TotalHardLinkCount(self) -> int: 

1372 """ 

1373 Read-only property to return the accumulated number of hardlinks to multiply-linked files. 

1374 

1375 Every file storage object referenced by more than one directory entry contributes its number of 

1376 directory entries. 

1377 

1378 :returns: Sum of directory entries over all hardlinked files. 

1379 """ 

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

1381 

1382 @readonly 

1383 def TotalHardLinkCount2(self) -> int: 

1384 """ 

1385 Read-only property to return the number of file storage objects that are hardlinked. 

1386 

1387 In contrast to :attr:`TotalHardLinkCount`, every hardlinked file contributes ``1``, regardless of how 

1388 many directory entries reference it. 

1389 

1390 :returns: Number of files referenced by more than one directory entry. 

1391 """ 

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

1393 

1394 @readonly 

1395 def TotalHardLinkCount3(self) -> int: 

1396 """ 

1397 Read-only property to return the number of file storage objects that are **not** hardlinked. 

1398 

1399 .. attention:: 

1400 

1401 Despite the name, this counts files referenced by exactly *one* directory entry. 

1402 

1403 :returns: Number of files referenced by exactly one directory entry. 

1404 """ 

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

1406 

1407 @readonly 

1408 def Size2(self) -> int: 

1409 """ 

1410 Read-only property to return the accumulated size of all hardlinked files, counted once each. 

1411 

1412 :returns: Sum of sizes over all files referenced by more than one directory entry. 

1413 """ 

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

1415 

1416 @readonly 

1417 def Size3(self) -> int: 

1418 """ 

1419 Read-only property to return the accumulated size of all hardlinked files, counted per directory entry. 

1420 

1421 In contrast to :attr:`Size2`, a file's size is multiplied by the number of directory entries 

1422 referencing it, so it reflects the size a filesystem without hardlink support would need. 

1423 

1424 :returns: Sum of sizes over all hardlinked files, weighted by their number of directory entries. 

1425 """ 

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

1427 

1428 @readonly 

1429 def TotalUniqueFileCount(self) -> int: 

1430 """ 

1431 Read-only property to return the number of distinct file storage objects, counting hardlinks to the same content once. 

1432 

1433 :returns: Number of unique files. 

1434 """ 

1435 return len(self._ids) 

1436 

1437 def RegisterBrokenSymbolicLink(self, symLink: SymbolicLink) -> None: 

1438 """ 

1439 Mark a symbolic link as broken and collect it at the root. 

1440 

1441 :param symLink: The symbolic link whose target doesn't exist. 

1442 """ 

1443 symLink._isBroken = True 

1444 self._brokenSymbolicLinks.append(symLink) 

1445 

1446 def RegisterUnconnectedSymbolicLink(self, symLink: SymbolicLink) -> None: 

1447 """ 

1448 Mark a symbolic link as out of range and collect it at the root. 

1449 

1450 :param symLink: The symbolic link whose target lies outside the scanned tree. 

1451 """ 

1452 symLink._isOutOfRange = True 

1453 self._unconnectedSymbolicLinks.append(symLink) 

1454 

1455 def Copy(self) -> Root: 

1456 """ 

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

1458 

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

1460 

1461 .. hint:: 

1462 

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

1464 There is no rescan or repeated aggregation needed. 

1465 

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

1467 """ 

1468 with Stopwatch() as sw: 

1469 root = Root(self._path, False) 

1470 root._size = self._size 

1471 

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

1473 subdir.Copy(root) 

1474 

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

1476 file.Copy(root) 

1477 

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

1479 link.Copy(root) 

1480 

1481 root._scanDuration = sw.Duration 

1482 root._aggregateDuration = 0.0 

1483 

1484 return root 

1485 

1486 def __repr__(self) -> str: 

1487 """ 

1488 Return a detailed string representation of this filesystem root. 

1489 

1490 :returns: The root's path and the number of directories, regular files and symbolic links below it. 

1491 """ 

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

1493 

1494 def __str__(self) -> str: 

1495 """ 

1496 Return a string representation of this filesystem element. 

1497 

1498 :returns: The element's name, without any path. 

1499 """ 

1500 return self._name 

1501 

1502 

1503@export 

1504class File(Base): 

1505 """ 

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

1507 

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

1509 """ 

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

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

1512 

1513 def __init__( 

1514 self, 

1515 id: int, 

1516 size: int, 

1517 parent: Nullable[Filename] = None 

1518 ) -> None: 

1519 """ 

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

1521 

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

1523 :param size: Optional, size of the file object. 

1524 :param parent: Optional, parent reference. 

1525 :raises ValueError: If parameter 'id' is None. 

1526 :raises TypeError: If parameter 'parent' is not of type :class:`Filename`. 

1527 """ 

1528 if id is None: 1528 ↛ 1529line 1528 didn't jump to line 1529 because the condition on line 1528 was never true

1529 raise ValueError(f"Parameter 'id' is None.") 

1530 elif not isinstance(id, int): 1530 ↛ 1531line 1530 didn't jump to line 1531 because the condition on line 1530 was never true

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

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

1533 raise ex 

1534 

1535 self._id = id 

1536 

1537 if parent is None: 

1538 super().__init__(size, None) 

1539 self._parents = [] 

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

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

1542 self._parents = [parent] 

1543 parent._file = self 

1544 else: 

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

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

1547 raise ex 

1548 

1549 @readonly 

1550 def ID(self) -> int: 

1551 """ 

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

1553 

1554 :returns: Unique file object identifier. 

1555 """ 

1556 return self._id 

1557 

1558 @readonly 

1559 def Parents(self) -> list[Filename]: 

1560 """ 

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

1562 

1563 .. hint:: 

1564 

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

1566 

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

1568 """ 

1569 return self._parents 

1570 

1571 def AddParent(self, filename: Filename) -> None: 

1572 """ 

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

1574 

1575 :param filename: Reference to a filename object. 

1576 :raises ValueError: If parameter 'filename' is None. 

1577 :raises TypeError: If parameter 'filename' is not of type :class:`Filename`. 

1578 :raises ToolingException: If the filename already references another file object. 

1579 """ 

1580 if filename is None: 1580 ↛ 1581line 1580 didn't jump to line 1581 because the condition on line 1580 was never true

1581 raise ValueError(f"Parameter 'filename' is None.") 

1582 elif not isinstance(filename, Filename): 1582 ↛ 1583line 1582 didn't jump to line 1583 because the condition on line 1582 was never true

1583 ex = TypeError("Parameter 'filename' is not of type 'Filename'.") 

1584 ex.add_note(f"Got type '{getFullyQualifiedName(filename)}'.") 

1585 raise ex 

1586 elif filename._file is not None: 1586 ↛ 1587line 1586 didn't jump to line 1587 because the condition on line 1586 was never true

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

1588 

1589 self._parents.append(filename) 

1590 filename._file = self 

1591 

1592 if filename._root is not None: 

1593 self._root = filename._root