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

615 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 13:02 +0000

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 os import scandir, readlink 

51from pathlib import Path 

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

53from typing import Iterator, cast 

54 

55from pyTooling.Decorators import readonly, export 

56from pyTooling.Exceptions import ToolingException 

57from pyTooling.MetaClasses import ExtendedType, abstractmethod 

58from pyTooling.Common import getFullyQualifiedName, zipdicts 

59from pyTooling.Warning import WarningCollector, Warning 

60from pyTooling.Stopwatch import Stopwatch 

61from pyTooling.Tree import Node 

62 

63 

64__all__ = ["_ParentType"] 

65 

66 

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

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

69 

70 

71@export 

72class FilesystemError(ToolingException): 

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

74 

75 

76@export 

77class PermissionWarning(Warning): 

78 """ 

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

80 

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

82 

83 .. attention:: 

84 

85 It's a non-critical warning: without a :class:`~pyTooling.Warning.WarningCollector` up the call-hierarchy, it's 

86 dropped silently. 

87 """ 

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

89 

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

91 """ 

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

93 

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

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

96 """ 

97 super().__init__(*args) 

98 self._path = path 

99 

100 @readonly 

101 def Path(self) -> Path: 

102 """ 

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

104 

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

106 """ 

107 return self._path 

108 

109 

110@export 

111class NodeKind(Enum): 

112 """ 

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

114 

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

116 """ 

117 Directory = 0 #: Node represents a directory. 

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

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

120 

121 

122@export 

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

124 """ 

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

126 

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

128 """ 

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

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

131 

132 def __init__( 

133 self, 

134 size: Nullable[int], 

135 root: Nullable[Root] 

136 ) -> None: 

137 """ 

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

139 

140 :param size: Size of the element. 

141 :param root: Reference to the filesystem root element. 

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

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

144 """ 

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

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

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

148 raise ex 

149 

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

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

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

153 raise ex 

154 

155 self._size = size 

156 self._root = root 

157 

158 @property 

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

160 """ 

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

162 

163 :returns: Root of the filesystem statistics scope. 

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

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

166 """ 

167 return self._root 

168 

169 @Root.setter 

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

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

172 raise ValueError("Parameter 'value' is None.") 

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

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

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

176 raise ex 

177 

178 self._root = value 

179 

180 @readonly 

181 def Size(self) -> int: 

182 """ 

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

184 

185 :returns: Size in Bytes. 

186 :raises FilesystemError: If size is not computed, yet. 

187 """ 

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

189 raise FilesystemError("Size is not computed, yet.") 

190 

191 return self._size 

192 

193 @abstractmethod 

194 def ToTree(self) -> Node: # type: ignore[empty-body] 

195 """ 

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

197 

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

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

200 

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

202 """ 

203 

204 

205@export 

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

207 """ 

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

209 

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

211 

212 .. hint:: 

213 

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

215 """ 

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

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

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

219 

220 def __init__( 

221 self, 

222 name: str, 

223 size: Nullable[int] = None, 

224 parent: Nullable[_ParentType] = None 

225 ) -> None: 

226 """ 

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

228 

229 :param name: Name of the element. 

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

231 :param parent: Optional, parent reference. 

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

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

234 """ 

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

236 raise ValueError("Parameter 'name' is None.") 

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

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

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

240 raise ex 

241 

242 self._name = name 

243 

244 if parent is None: 

245 super().__init__(size, None) 

246 self._parent = None 

247 else: 

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

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

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

251 raise ex 

252 

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

254 self._parent = parent 

255 

256 self._linkSources = [] 

257 

258 @property 

259 def Parent(self) -> _ParentType: 

260 """ 

261 Property to access the element's parent. 

262 

263 :returns: Parent element. 

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

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

266 """ 

267 return self._parent 

268 

269 @Parent.setter 

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

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

272 raise ValueError("Parameter 'value' is None.") 

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

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

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

276 raise ex 

277 

278 self._parent = value 

279 

280 if value._root is not None: 

281 self._root = value._root 

282 

283 @readonly 

284 def Name(self) -> str: 

285 """ 

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

287 

288 :returns: Element name. 

289 """ 

290 return self._name 

291 

292 @readonly 

293 @abstractmethod 

294 def Path(self) -> Path: # type: ignore[empty-body] 

295 """ 

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

297 

298 :returns: Path of the element. 

299 """ 

300 

301 @readonly 

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

303 """ 

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

305 

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

307 """ 

308 return self._linkSources 

309 

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

311 """ 

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

313 

314 :param source: The referenced symbolic link. 

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

316 """ 

317 if not isinstance(source, SymbolicLink): 

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

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

320 raise ex 

321 

322 source._isConnected = True 

323 source._isBroken = False 

324 source._isOutOfRange = False 

325 self._linkSources.append(source) 

326 

327 

328@export 

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

330 """ 

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

332 

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

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

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

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

337 

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

339 

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

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

342 """ 

343 

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

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

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

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

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

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

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

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

352 

353 def __init__( 

354 self, 

355 name: str, 

356 collectSubdirectories: bool = False, 

357 parent: Nullable[Directory] = None 

358 ) -> None: 

359 """ 

360 Initialize the directory with name and parent reference. 

361 

362 If ``collectSubdirectories`` is ``True``, a directory that can't be read is reported as a :class:`PermissionWarning` 

363 to a :class:`~pyTooling.Warning.WarningCollector`; without a collector, it's dropped. 

364 

365 :param name: Name of the element. 

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

367 :param parent: Optional, parent reference. 

368 """ 

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

370 

371 self._path = None 

372 self._subdirectories = {} 

373 self._files = {} 

374 self._symbolicLinks = {} 

375 self._filesSize = 0 

376 self._collapsed = False 

377 self._scanDuration = None 

378 self._aggregateDuration = None 

379 

380 if parent is not None: 

381 parent._subdirectories[name] = self 

382 

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

384 self._root = parent._root 

385 

386 if collectSubdirectories: 

387 self.CollectSubdirectories() 

388 

389 def CollectSubdirectories(self) -> None: 

390 """ 

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

392 

393 A directory that can't be read is reported as a :class:`PermissionWarning` to a 

394 :class:`~pyTooling.Warning.WarningCollector`; without a collector, it's dropped. 

395 """ 

396 self.ScanSubdirectories() 

397 self.AggregateSizes() 

398 

399 def ScanSubdirectories(self) -> None: 

400 """ 

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

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

403 

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

405 storage object is assumed. 

406 

407 A directory that can't be read is reported as a :class:`PermissionWarning` to a 

408 :class:`~pyTooling.Warning.WarningCollector` and skipped, so the scan continues and the collected statistics are 

409 incomplete by exactly that path. Without a collector, the warning is dropped. 

410 

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

412 :raises FilesystemError: If the directory contains an element that is neither a directory, a file nor a 

413 symbolic link. 

414 """ 

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

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

417 

418 with Stopwatch() as sw1: 

419 try: 

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

421 except PermissionError as ex: 

422 WarningCollector.Raise(PermissionWarning(self.Path), ex) 

423 return 

424 

425 for dirEntry in items: 

426 if dirEntry.is_dir(follow_symlinks=False): 

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

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

429 id = dirEntry.inode() 

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

431 file = root._ids[id] 

432 

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

434 else: 

435 s = dirEntry.stat(follow_symlinks=False) 

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

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

438 

439 root._ids[id] = file 

440 elif dirEntry.is_symlink(): 

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

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

443 else: 

444 raise FilesystemError("Unknown directory element.") 

445 

446 self._scanDuration = sw1.Duration 

447 

448 def ResolveSymbolicLinks(self) -> None: 

449 """ 

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

451 

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

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

454 """ 

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

456 dir.ResolveSymbolicLinks() 

457 

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

459 if link._target.is_absolute(): 

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

461 pass 

462 else: 

463 target = self 

464 for elem in link._target.parts: 

465 if elem == ".": 

466 continue 

467 elif elem == "..": 

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

469 self._root.RegisterUnconnectedSymbolicLink(link) 

470 break 

471 

472 continue 

473 

474 try: 

475 target = target._subdirectories[elem] 

476 continue 

477 except KeyError: 

478 pass 

479 

480 try: 

481 target = target._files[elem] 

482 continue 

483 except KeyError: 

484 pass 

485 

486 try: 

487 target = target._symbolicLinks[elem] 

488 continue 

489 except KeyError: 

490 self._root.RegisterBrokenSymbolicLink(link) 

491 break 

492 else: 

493 target.AddLinkSources(link) 

494 

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

496 """ 

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

498 

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

500 files are returned and handed up the recursion. 

501 

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

503 """ 

504 with Stopwatch() as sw2: 

505 aggregatedFiles = set() 

506 

507 self._size = 0 

508 self._filesSize = 0 

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

510 aggregatedFiles |= dir.AggregateSizes() 

511 self._size += dir._size 

512 

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

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

515 self._filesSize += file._size 

516 aggregatedFiles.add(file) 

517 

518 self._size += self._filesSize 

519 

520 self._aggregateDuration = sw2.Duration 

521 

522 return aggregatedFiles 

523 

524 @Element.Root.setter 

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

526 Element.Root.fset(self, value) 

527 

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

529 subdir.Root = value 

530 

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

532 file.Root = value 

533 

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

535 link.Root = value 

536 

537 @Element.Parent.setter 

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

539 Element.Parent.fset(self, value) 

540 

541 value._subdirectories[self._name] = self 

542 

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

544 self.Root = value 

545 

546 @readonly 

547 def Count(self) -> int: 

548 """ 

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

550 

551 :returns: Number of files plus subdirectories. 

552 """ 

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

554 

555 @readonly 

556 def FileCount(self) -> int: 

557 """ 

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

559 

560 .. hint:: 

561 

562 Files include regular files and symbolic links. 

563 

564 :returns: Number of files. 

565 """ 

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

567 

568 @readonly 

569 def RegularFileCount(self) -> int: 

570 """ 

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

572 

573 :returns: Number of regular files. 

574 """ 

575 return len(self._files) 

576 

577 @readonly 

578 def SymbolicLinkCount(self) -> int: 

579 """ 

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

581 

582 :returns: Number of symbolic links. 

583 """ 

584 return len(self._symbolicLinks) 

585 

586 @readonly 

587 def SubdirectoryCount(self) -> int: 

588 """ 

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

590 

591 :returns: Number of subdirectories. 

592 """ 

593 return len(self._subdirectories) 

594 

595 @readonly 

596 def TotalFileCount(self) -> int: 

597 """ 

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

599 

600 .. hint:: 

601 

602 Files include regular files and symbolic links. 

603 

604 :returns: Total number of files. 

605 """ 

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

607 

608 @readonly 

609 def TotalRegularFileCount(self) -> int: 

610 """ 

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

612 

613 :returns: Total number of regular files. 

614 """ 

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

616 

617 @readonly 

618 def TotalSymbolicLinkCount(self) -> int: 

619 """ 

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

621 

622 :returns: Total number of symbolic links. 

623 """ 

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

625 

626 @readonly 

627 def TotalSubdirectoryCount(self) -> int: 

628 """ 

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

630 

631 :returns: Total number of subdirectories. 

632 """ 

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

634 

635 @readonly 

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

637 """ 

638 Iterate all direct subdirectories of the directory. 

639 

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

641 """ 

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

643 

644 @readonly 

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

646 """ 

647 Iterate all direct files of the directory. 

648 

649 .. hint:: 

650 

651 Files include regular files and symbolic links. 

652 

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

654 """ 

655 return (f for f in (*self._files.values(), *self._symbolicLinks.values())) 

656 

657 @readonly 

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

659 """ 

660 Iterate all direct regular files of the directory. 

661 

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

663 """ 

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

665 

666 @readonly 

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

668 """ 

669 Iterate all direct symbolic links of the directory. 

670 

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

672 """ 

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

674 

675 @readonly 

676 def Path(self) -> Path: 

677 """ 

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

679 

680 :returns: Path to the directory. 

681 :raises FilesystemError: If no parent is set. 

682 """ 

683 if self._path is not None: 

684 return self._path 

685 

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

687 raise FilesystemError("No parent or root set for directory.") 

688 

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

690 return self._path 

691 

692 @readonly 

693 def ScanDuration(self) -> float: 

694 """ 

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

696 

697 :returns: The scan duration in seconds. 

698 :raises FilesystemError: If the directory was not scanned. 

699 """ 

700 if self._scanDuration is None: 

701 raise FilesystemError("Directory was not scanned, yet.") 

702 

703 return self._scanDuration 

704 

705 @readonly 

706 def AggregateDuration(self) -> float: 

707 """ 

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

709 

710 :returns: The aggregation duration in seconds. 

711 :raises FilesystemError: If the directory properties were not aggregated. 

712 """ 

713 if self._scanDuration is None: 

714 raise FilesystemError("Directory properties were not aggregated, yet.") 

715 

716 return self._aggregateDuration 

717 

718 def __hash__(self) -> int: 

719 """ 

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

721 

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

723 object's identity and not from its name. 

724 

725 :returns: Hash of this filesystem element. 

726 """ 

727 return hash(id(self)) 

728 

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

730 """ 

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

732 

733 A parent directory is yielded before its children. 

734 

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

736 """ 

737 # pre-order 

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

739 yield directory 

740 yield from directory.IterateDirectories() 

741 

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

743 """ 

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

745 

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

747 

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

749 """ 

750 # post-order 

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

752 yield from directory.IterateFiles() 

753 

754 yield from self._files.values() 

755 yield from self._symbolicLinks.values() 

756 

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

758 """ 

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

760 

761 .. hint:: 

762 

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

764 There is no rescan or repeated aggregation needed. 

765 

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

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

768 """ 

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

770 dir._size = self._size 

771 

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

773 subdir.Copy(dir) 

774 

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

776 file.Copy(dir) 

777 

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

779 link.Copy(dir) 

780 

781 return dir 

782 

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

784 """ 

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

786 

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

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

789 

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

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

792 """ 

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

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

795 if func(self): 

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

797 self._collapsed = True 

798 self._subdirectories.clear() 

799 self._files.clear() 

800 self._symbolicLinks.clear() 

801 

802 return True 

803 else: 

804 return False 

805 

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

807 collapsible = True 

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

809 result = subdir.Collapse(func) 

810 collapsible = collapsible and result 

811 

812 if collapsible: 

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

814 self._collapsed = True 

815 self._subdirectories.clear() 

816 self._files.clear() 

817 self._symbolicLinks.clear() 

818 

819 return True 

820 else: 

821 return False 

822 

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

824 """ 

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

826 

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

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

829 

830 ``kind`` 

831 The node's kind: :attr:`NodeKind.Directory`. 

832 ``size`` 

833 The directory's aggregated size. 

834 

835 Its subdirectories, files and symbolic links become the node's children, in this order. 

836 

837 :param format: Optional, a user defined formatting function for the tree nodes of this directory and its elements. 

838 :returns: A tree node representing this directory. 

839 """ 

840 if format is None: 840 ↛ 841line 840 didn't jump to line 841 because the condition on line 840 was never true

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

842 """ 

843 Nested function rendering a tree node as one line. 

844 

845 :param node: The tree node to render. 

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

847 """ 

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

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

850 

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

852 value=self, 

853 keyValuePairs={ 

854 "kind": NodeKind.Directory, 

855 "size": self._size 

856 }, 

857 format=format 

858 ) 

859 directoryNode.AddChildren( 

860 e.ToTree(format) for e in (*self._subdirectories.values(), *self._files.values(), *self._symbolicLinks.values()) 

861 ) 

862 

863 return directoryNode 

864 

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

866 """ 

867 Compare two Directory instances for equality. 

868 

869 :param other: Parameter to compare against. 

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

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

872 """ 

873 if not isinstance(other, Directory): 

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

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

876 raise ex 

877 

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

879 return False 

880 

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

882 return False 

883 

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

885 return False 

886 

887 return True 

888 

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

890 """ 

891 Compare two Directory instances for inequality. 

892 

893 :param other: Parameter to compare against. 

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

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

896 """ 

897 return not self.__eq__(other) 

898 

899 def __repr__(self) -> str: 

900 """ 

901 Return a detailed string representation of this directory. 

902 

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

904 """ 

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

906 

907 def __str__(self) -> str: 

908 """ 

909 Return a string representation of this filesystem element. 

910 

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

912 """ 

913 return self._name 

914 

915 

916@export 

917class Filename(Element[Directory]): 

918 """ 

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

920 

921 .. hint:: 

922 

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

924 object. 

925 """ 

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

927 

928 def __init__( 

929 self, 

930 name: str, 

931 file: Nullable[File] = None, 

932 parent: Nullable[Directory] = None 

933 ) -> None: 

934 """ 

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

936 

937 :param name: Name of the file. 

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

939 :param parent: Optional, parent reference. 

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

941 """ 

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

943 

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

945 self._file = None 

946 else: 

947 if not isinstance(file, File): 

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

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

950 raise ex 

951 

952 self._file = file 

953 file._parents.append(self) 

954 

955 if parent is not None: 

956 parent._files[name] = self 

957 

958 if parent._root is not None: 

959 self._root = parent._root 

960 

961 @Element.Root.setter 

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

963 Element.Root.fset(self, value) 

964 

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

966 self._file.Root = value 

967 

968 @Element.Parent.setter 

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

970 Element.Parent.fset(self, value) 

971 

972 value._files[self._name] = self 

973 

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

975 self.Root = value 

976 

977 @readonly 

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

979 """ 

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

981 

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

983 """ 

984 return self._file 

985 

986 @readonly 

987 def Size(self) -> int: 

988 """ 

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

990 

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

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

993 """ 

994 if self._file is None: 

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

996 

997 return self._file._size 

998 

999 @readonly 

1000 def Path(self) -> Path: 

1001 """ 

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

1003 

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

1005 

1006 :returns: Absolute path of the file. 

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

1008 """ 

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

1010 raise ToolingException("Filename has no parent object.") 

1011 

1012 return self._parent.Path / self._name 

1013 

1014 def __hash__(self) -> int: 

1015 """ 

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

1017 

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

1019 object's identity and not from its name. 

1020 

1021 :returns: Hash of this filesystem element. 

1022 """ 

1023 return hash(id(self)) 

1024 

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

1026 """ 

1027 Copy this filename into another filesystem statistics scope. 

1028 

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

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

1031 

1032 :param parent: The directory in the target scope the copy is registered at. 

1033 :returns: The copied filename. 

1034 """ 

1035 fileID = self._file._id 

1036 

1037 if fileID in parent._root._ids: 

1038 file = parent._root._ids[fileID] 

1039 else: 

1040 fileSize = self._file._size 

1041 file = File(fileID, fileSize) 

1042 

1043 parent._root._ids[fileID] = file 

1044 

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

1046 

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

1048 """ 

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

1050 

1051 :param format: Optional, a user defined formatting function for the tree node. 

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

1053 """ 

1054 if format is None: 1054 ↛ 1055line 1054 didn't jump to line 1055 because the condition on line 1054 was never true

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

1056 """ 

1057 Nested function rendering a tree node as one line. 

1058 

1059 :param node: The tree node to render. 

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

1061 """ 

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

1063 

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

1065 value=self, 

1066 keyValuePairs={ 

1067 "kind": NodeKind.File, 

1068 "size": self._size 

1069 }, 

1070 format=format 

1071 ) 

1072 

1073 return fileNode 

1074 

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

1076 """ 

1077 Compare two Filename instances for equality. 

1078 

1079 :param other: Parameter to compare against. 

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

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

1082 """ 

1083 if not isinstance(other, Filename): 

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

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

1086 raise ex 

1087 

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

1089 

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

1091 """ 

1092 Compare two Filename instances for inequality. 

1093 

1094 :param other: Parameter to compare against. 

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

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

1097 """ 

1098 if not isinstance(other, Filename): 

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

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

1101 raise ex 

1102 

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

1104 

1105 def __repr__(self) -> str: 

1106 """ 

1107 Return a detailed string representation of this filename. 

1108 

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

1110 """ 

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

1112 

1113 def __str__(self) -> str: 

1114 """ 

1115 Return a string representation of this filesystem element. 

1116 

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

1118 """ 

1119 return self._name 

1120 

1121 

1122@export 

1123class SymbolicLink(Element[Directory]): 

1124 """ 

1125 A symbolic link in the filesystem statistics scope. 

1126 

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

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

1129 """ 

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

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

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

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

1134 

1135 def __init__( 

1136 self, 

1137 name: str, 

1138 target: Path, 

1139 parent: Nullable[Directory] 

1140 ) -> None: 

1141 """ 

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

1143 

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

1145 broken or out of range. 

1146 

1147 :param name: Name of the symbolic link. 

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

1149 :param parent: Parent directory of the symbolic link. 

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

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

1152 """ 

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

1154 

1155 if target is None: 1155 ↛ 1156line 1155 didn't jump to line 1156 because the condition on line 1155 was never true

1156 raise ValueError("Parameter 'target' is None.") 

1157 elif not isinstance(target, Path): 1157 ↛ 1158line 1157 didn't jump to line 1158 because the condition on line 1157 was never true

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

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

1160 raise ex 

1161 

1162 self._target = target 

1163 self._isConnected = False 

1164 self._isBroken = None 

1165 self._isOutOfRange = None 

1166 

1167 if parent is not None: 1167 ↛ exitline 1167 didn't return from function '__init__' because the condition on line 1167 was always true

1168 parent._symbolicLinks[name] = self 

1169 

1170 if parent._root is not None: 1170 ↛ exitline 1170 didn't return from function '__init__' because the condition on line 1170 was always true

1171 self._root = parent._root 

1172 

1173 @readonly 

1174 def Path(self) -> Path: 

1175 """ 

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

1177 

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

1179 

1180 :returns: Path of the symbolic link. 

1181 """ 

1182 return self._parent.Path / self._name 

1183 

1184 @readonly 

1185 def Target(self) -> Path: 

1186 """ 

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

1188 

1189 :returns: Target path of the symbolic link. 

1190 """ 

1191 return self._target 

1192 

1193 @readonly 

1194 def IsConnected(self) -> bool: 

1195 """ 

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

1197 

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

1199 """ 

1200 return self._isConnected 

1201 

1202 @readonly 

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

1204 """ 

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

1206 

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

1208 """ 

1209 return self._isBroken 

1210 

1211 @readonly 

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

1213 """ 

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

1215 

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

1217 """ 

1218 return self._isOutOfRange 

1219 

1220 def __hash__(self) -> int: 

1221 """ 

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

1223 

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

1225 object's identity and not from its name. 

1226 

1227 :returns: Hash of this filesystem element. 

1228 """ 

1229 return hash(id(self)) 

1230 

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

1232 """ 

1233 Copy this symbolic link into another filesystem statistics scope. 

1234 

1235 :param parent: The directory in the target scope the copy is registered at. 

1236 :returns: The copied symbolic link, unresolved. 

1237 """ 

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

1239 

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

1241 """ 

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

1243 

1244 :param format: Optional, a user defined formatting function for the tree node. 

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

1246 """ 

1247 if format is None: 1247 ↛ 1248line 1247 didn't jump to line 1248 because the condition on line 1247 was never true

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

1249 """ 

1250 Nested function rendering a tree node as one line. 

1251 

1252 :param node: The tree node to render. 

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

1254 """ 

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

1256 

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

1258 value=self, 

1259 keyValuePairs={ 

1260 "kind": NodeKind.SymbolicLink, 

1261 "size": self._size 

1262 }, 

1263 format=format 

1264 ) 

1265 

1266 return symbolicLinkNode 

1267 

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

1269 """ 

1270 Compare two SymbolicLink instances for equality. 

1271 

1272 :param other: Parameter to compare against. 

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

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

1275 """ 

1276 if not isinstance(other, SymbolicLink): 

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

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

1279 raise ex 

1280 

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

1282 

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

1284 """ 

1285 Compare two SymbolicLink instances for inequality. 

1286 

1287 :param other: Parameter to compare against. 

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

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

1290 """ 

1291 if not isinstance(other, SymbolicLink): 

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

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

1294 raise ex 

1295 

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

1297 

1298 def __repr__(self) -> str: 

1299 """ 

1300 Return a detailed string representation of this symbolic link. 

1301 

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

1303 """ 

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

1305 

1306 def __str__(self) -> str: 

1307 """ 

1308 Return a string representation of this filesystem element. 

1309 

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

1311 """ 

1312 return self._name 

1313 

1314 

1315@export 

1316class Root(Directory): 

1317 """ 

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

1319 """ 

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

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

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

1323 

1324 def __init__( 

1325 self, 

1326 rootDirectory: Path, 

1327 collectSubdirectories: bool = True 

1328 ) -> None: 

1329 """ 

1330 Initialize a filesystem statistics scope for the given directory. 

1331 

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

1333 away, so the root is usable as soon as it exists. A directory that can't be read is reported as a 

1334 :class:`PermissionWarning` to a :class:`~pyTooling.Warning.WarningCollector`; without a collector, it's dropped. 

1335 

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

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

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

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

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

1341 """ 

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

1343 raise ValueError("Parameter 'rootDirectory' is None.") 

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

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

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

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

1348 

1349 self._ids = {} 

1350 self._brokenSymbolicLinks = [] 

1351 self._unconnectedSymbolicLinks = [] 

1352 

1353 super().__init__(rootDirectory.name) 

1354 self._root = self 

1355 self._path = rootDirectory 

1356 

1357 if collectSubdirectories: 

1358 self.CollectSubdirectories() 

1359 self.ResolveSymbolicLinks() 

1360 

1361 @readonly 

1362 def Path(self) -> Path: 

1363 """ 

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

1365 

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

1367 """ 

1368 return self._path 

1369 

1370 @readonly 

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

1372 """ 

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

1374 

1375 :returns: List of broken symbolic links. 

1376 """ 

1377 return self._brokenSymbolicLinks 

1378 

1379 @readonly 

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

1381 """ 

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

1383 

1384 :returns: List of unconnected symbolic links. 

1385 """ 

1386 return self._unconnectedSymbolicLinks 

1387 

1388 @readonly 

1389 def TotalHardLinkCount(self) -> int: 

1390 """ 

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

1392 

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

1394 directory entries. 

1395 

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

1397 """ 

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

1399 

1400 @readonly 

1401 def TotalHardLinkCount2(self) -> int: 

1402 """ 

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

1404 

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

1406 many directory entries reference it. 

1407 

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

1409 """ 

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

1411 

1412 @readonly 

1413 def TotalHardLinkCount3(self) -> int: 

1414 """ 

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

1416 

1417 .. attention:: 

1418 

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

1420 

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

1422 """ 

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

1424 

1425 @readonly 

1426 def Size2(self) -> int: 

1427 """ 

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

1429 

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

1431 """ 

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

1433 

1434 @readonly 

1435 def Size3(self) -> int: 

1436 """ 

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

1438 

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

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

1441 

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

1443 """ 

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

1445 

1446 @readonly 

1447 def TotalUniqueFileCount(self) -> int: 

1448 """ 

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

1450 

1451 :returns: Number of unique files. 

1452 """ 

1453 return len(self._ids) 

1454 

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

1456 """ 

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

1458 

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

1460 """ 

1461 symLink._isBroken = True 

1462 self._brokenSymbolicLinks.append(symLink) 

1463 

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

1465 """ 

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

1467 

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

1469 """ 

1470 symLink._isOutOfRange = True 

1471 self._unconnectedSymbolicLinks.append(symLink) 

1472 

1473 def Copy(self) -> Root: 

1474 """ 

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

1476 

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

1478 

1479 .. hint:: 

1480 

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

1482 There is no rescan or repeated aggregation needed. 

1483 

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

1485 """ 

1486 with Stopwatch() as sw: 

1487 root = Root(self._path, False) 

1488 root._size = self._size 

1489 

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

1491 subdir.Copy(root) 

1492 

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

1494 file.Copy(root) 

1495 

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

1497 link.Copy(root) 

1498 

1499 root._scanDuration = sw.Duration 

1500 root._aggregateDuration = 0.0 

1501 

1502 return root 

1503 

1504 def __repr__(self) -> str: 

1505 """ 

1506 Return a detailed string representation of this filesystem root. 

1507 

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

1509 """ 

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

1511 

1512 def __str__(self) -> str: 

1513 """ 

1514 Return a string representation of this filesystem element. 

1515 

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

1517 """ 

1518 return self._name 

1519 

1520 

1521@export 

1522class File(Base): 

1523 """ 

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

1525 

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

1527 """ 

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

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

1530 

1531 def __init__( 

1532 self, 

1533 id: int, 

1534 size: int, 

1535 parent: Nullable[Filename] = None 

1536 ) -> None: 

1537 """ 

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

1539 

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

1541 :param size: Size of the file object. 

1542 :param parent: Optional, parent reference. 

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

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

1545 """ 

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

1547 raise ValueError("Parameter 'id' is None.") 

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

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

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

1551 raise ex 

1552 

1553 self._id = id 

1554 

1555 if parent is None: 

1556 super().__init__(size, None) 

1557 self._parents = [] 

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

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

1560 self._parents = [parent] 

1561 parent._file = self 

1562 else: 

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

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

1565 raise ex 

1566 

1567 @readonly 

1568 def ID(self) -> int: 

1569 """ 

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

1571 

1572 :returns: Unique file object identifier. 

1573 """ 

1574 return self._id 

1575 

1576 @readonly 

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

1578 """ 

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

1580 

1581 .. hint:: 

1582 

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

1584 

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

1586 """ 

1587 return self._parents 

1588 

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

1590 """ 

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

1592 

1593 :param filename: Reference to a filename object. 

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

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

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

1597 """ 

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

1599 raise ValueError("Parameter 'filename' is None.") 

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

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

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

1603 raise ex 

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

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

1606 

1607 self._parents.append(filename) 

1608 filename._file = self 

1609 

1610 if filename._root is not None: 

1611 self._root = filename._root 

1612 

1613 def ToTree(self) -> Node: 

1614 """ 

1615 Convert this file storage object to a node of a :mod:`pyTooling.Tree`. 

1616 

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

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

1619 

1620 ``kind`` 

1621 The node's kind: :attr:`NodeKind.File`. 

1622 ``size`` 

1623 The file object's size. 

1624 

1625 :returns: A tree node carrying this file object, its kind and its size. 

1626 """ 

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

1628 """ 

1629 Nested function rendering a tree node as one line. 

1630 

1631 :param node: The tree node to render. 

1632 :returns: The node's size in MiB, followed by the file object's ID. 

1633 """ 

1634 return f"{node['size'] * 1e-6:7.1f} MiB file object {node._value.ID}" 

1635 

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

1637 value=self, 

1638 keyValuePairs={ 

1639 "kind": NodeKind.File, 

1640 "size": self._size 

1641 }, 

1642 format=format 

1643 ) 

1644 

1645 return fileNode