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

607 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-13 00:18 +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 FilesystemError(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("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 FilesystemError: 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 FilesystemError("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("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("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("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 FilesystemError: If this directory isn't attached to a :class:`Root`, which owns the ID table. 

402 :raises FilesystemError: 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 FilesystemError(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 WarningCollector.Raise(PermissionWarning(self.Path), ex) 

413 return 

414 

415 for dirEntry in items: 

416 if dirEntry.is_dir(follow_symlinks=False): 

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

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

419 id = dirEntry.inode() 

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

421 file = root._ids[id] 

422 

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

424 else: 

425 s = dirEntry.stat(follow_symlinks=False) 

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

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

428 

429 root._ids[id] = file 

430 elif dirEntry.is_symlink(): 

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

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

433 else: 

434 raise FilesystemError("Unknown directory element.") 

435 

436 self._scanDuration = sw1.Duration 

437 

438 def ResolveSymbolicLinks(self) -> None: 

439 """ 

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

441 

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

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

444 """ 

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

446 dir.ResolveSymbolicLinks() 

447 

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

449 if link._target.is_absolute(): 

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

451 pass 

452 else: 

453 target = self 

454 for elem in link._target.parts: 

455 if elem == ".": 

456 continue 

457 elif elem == "..": 

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

459 self._root.RegisterUnconnectedSymbolicLink(link) 

460 break 

461 

462 continue 

463 

464 try: 

465 target = target._subdirectories[elem] 

466 continue 

467 except KeyError: 

468 pass 

469 

470 try: 

471 target = target._files[elem] 

472 continue 

473 except KeyError: 

474 pass 

475 

476 try: 

477 target = target._symbolicLinks[elem] 

478 continue 

479 except KeyError: 

480 self._root.RegisterBrokenSymbolicLink(link) 

481 break 

482 else: 

483 target.AddLinkSources(link) 

484 

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

486 """ 

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

488 

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

490 files are returned and handed up the recursion. 

491 

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

493 """ 

494 with Stopwatch() as sw2: 

495 aggregatedFiles = set() 

496 

497 self._size = 0 

498 self._filesSize = 0 

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

500 aggregatedFiles |= dir.AggregateSizes() 

501 self._size += dir._size 

502 

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

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

505 self._filesSize += file._size 

506 aggregatedFiles.add(file) 

507 

508 self._size += self._filesSize 

509 

510 self._aggregateDuration = sw2.Duration 

511 

512 return aggregatedFiles 

513 

514 @Element.Root.setter 

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

516 Element.Root.fset(self, value) 

517 

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

519 subdir.Root = value 

520 

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

522 file.Root = value 

523 

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

525 link.Root = value 

526 

527 @Element.Parent.setter 

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

529 Element.Parent.fset(self, value) 

530 

531 value._subdirectories[self._name] = self 

532 

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

534 self.Root = value 

535 

536 @readonly 

537 def Count(self) -> int: 

538 """ 

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

540 

541 :returns: Number of files plus subdirectories. 

542 """ 

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

544 

545 @readonly 

546 def FileCount(self) -> int: 

547 """ 

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

549 

550 .. hint:: 

551 

552 Files include regular files and symbolic links. 

553 

554 :returns: Number of files. 

555 """ 

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

557 

558 @readonly 

559 def RegularFileCount(self) -> int: 

560 """ 

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

562 

563 :returns: Number of regular files. 

564 """ 

565 return len(self._files) 

566 

567 @readonly 

568 def SymbolicLinkCount(self) -> int: 

569 """ 

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

571 

572 :returns: Number of symbolic links. 

573 """ 

574 return len(self._symbolicLinks) 

575 

576 @readonly 

577 def SubdirectoryCount(self) -> int: 

578 """ 

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

580 

581 :returns: Number of subdirectories. 

582 """ 

583 return len(self._subdirectories) 

584 

585 @readonly 

586 def TotalFileCount(self) -> int: 

587 """ 

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

589 

590 .. hint:: 

591 

592 Files include regular files and symbolic links. 

593 

594 :returns: Total number of files. 

595 """ 

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

597 

598 @readonly 

599 def TotalRegularFileCount(self) -> int: 

600 """ 

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

602 

603 :returns: Total number of regular files. 

604 """ 

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

606 

607 @readonly 

608 def TotalSymbolicLinkCount(self) -> int: 

609 """ 

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

611 

612 :returns: Total number of symbolic links. 

613 """ 

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

615 

616 @readonly 

617 def TotalSubdirectoryCount(self) -> int: 

618 """ 

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

620 

621 :returns: Total number of subdirectories. 

622 """ 

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

624 

625 @readonly 

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

627 """ 

628 Iterate all direct subdirectories of the directory. 

629 

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

631 """ 

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

633 

634 @readonly 

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

636 """ 

637 Iterate all direct files of the directory. 

638 

639 .. hint:: 

640 

641 Files include regular files and symbolic links. 

642 

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

644 """ 

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

646 

647 @readonly 

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

649 """ 

650 Iterate all direct regular files of the directory. 

651 

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

653 """ 

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

655 

656 @readonly 

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

658 """ 

659 Iterate all direct symbolic links of the directory. 

660 

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

662 """ 

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

664 

665 @readonly 

666 def Path(self) -> Path: 

667 """ 

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

669 

670 :returns: Path to the directory. 

671 :raises FilesystemError: If no parent is set. 

672 """ 

673 if self._path is not None: 

674 return self._path 

675 

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

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

678 

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

680 return self._path 

681 

682 @readonly 

683 def ScanDuration(self) -> float: 

684 """ 

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

686 

687 :returns: The scan duration in seconds. 

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

689 """ 

690 if self._scanDuration is None: 

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

692 

693 return self._scanDuration 

694 

695 @readonly 

696 def AggregateDuration(self) -> float: 

697 """ 

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

699 

700 :returns: The aggregation duration in seconds. 

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

702 """ 

703 if self._scanDuration is None: 

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

705 

706 return self._aggregateDuration 

707 

708 def __hash__(self) -> int: 

709 """ 

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

711 

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

713 object's identity and not from its name. 

714 

715 :returns: Hash of this filesystem element. 

716 """ 

717 return hash(id(self)) 

718 

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

720 """ 

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

722 

723 A parent directory is yielded before its children. 

724 

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

726 """ 

727 # pre-order 

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

729 yield directory 

730 yield from directory.IterateDirectories() 

731 

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

733 """ 

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

735 

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

737 

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

739 """ 

740 # post-order 

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

742 yield from directory.IterateFiles() 

743 

744 yield from self._files.values() 

745 yield from self._symbolicLinks.values() 

746 

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

748 """ 

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

750 

751 .. hint:: 

752 

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

754 There is no rescan or repeated aggregation needed. 

755 

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

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

758 """ 

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

760 dir._size = self._size 

761 

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

763 subdir.Copy(dir) 

764 

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

766 file.Copy(dir) 

767 

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

769 link.Copy(dir) 

770 

771 return dir 

772 

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

774 """ 

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

776 

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

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

779 

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

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

782 """ 

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

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

785 if func(self): 

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

787 self._collapsed = True 

788 self._subdirectories.clear() 

789 self._files.clear() 

790 self._symbolicLinks.clear() 

791 

792 return True 

793 else: 

794 return False 

795 

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

797 collapsible = True 

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

799 result = subdir.Collapse(func) 

800 collapsible = collapsible and result 

801 

802 if collapsible: 

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

804 self._collapsed = True 

805 self._subdirectories.clear() 

806 self._files.clear() 

807 self._symbolicLinks.clear() 

808 

809 return True 

810 else: 

811 return False 

812 

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

814 """ 

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

816 

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

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

819 

820 ``kind`` 

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

822 ``size`` 

823 The directory's aggregated size. 

824 

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

826 :returns: A tree node representing this directory. 

827 """ 

828 if format is None: 

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

830 """ 

831 Nested function rendering a tree node as one line. 

832 

833 :param node: The tree node to render. 

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

835 """ 

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

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

838 

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

840 value=self, 

841 keyValuePairs={ 

842 "kind": NodeKind.File, 

843 "size": self._size 

844 }, 

845 format=format 

846 ) 

847 directoryNode.AddChildren( 

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

849 ) 

850 

851 return directoryNode 

852 

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

854 """ 

855 Compare two Directory instances for equality. 

856 

857 :param other: Parameter to compare against. 

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

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

860 """ 

861 if not isinstance(other, Directory): 

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

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

864 raise ex 

865 

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

867 return False 

868 

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

870 return False 

871 

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

873 return False 

874 

875 return True 

876 

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

878 """ 

879 Compare two Directory instances for inequality. 

880 

881 :param other: Parameter to compare against. 

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

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

884 """ 

885 return not self.__eq__(other) 

886 

887 def __repr__(self) -> str: 

888 """ 

889 Return a detailed string representation of this directory. 

890 

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

892 """ 

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

894 

895 def __str__(self) -> str: 

896 """ 

897 Return a string representation of this filesystem element. 

898 

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

900 """ 

901 return self._name 

902 

903 

904@export 

905class Filename(Element[Directory]): 

906 """ 

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

908 

909 .. hint:: 

910 

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

912 object. 

913 """ 

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

915 

916 def __init__( 

917 self, 

918 name: str, 

919 file: Nullable[File] = None, 

920 parent: Nullable[Directory] = None 

921 ) -> None: 

922 """ 

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

924 

925 :param name: Name of the file. 

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

927 :param parent: Optional, parent reference. 

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

929 """ 

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

931 

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

933 self._file = None 

934 else: 

935 if not isinstance(file, File): 

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

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

938 raise ex 

939 

940 self._file = file 

941 file._parents.append(self) 

942 

943 if parent is not None: 

944 parent._files[name] = self 

945 

946 if parent._root is not None: 

947 self._root = parent._root 

948 

949 @Element.Root.setter 

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

951 Element.Root.fset(self, value) 

952 

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

954 self._file.Root = value 

955 

956 @Element.Parent.setter 

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

958 Element.Parent.fset(self, value) 

959 

960 value._files[self._name] = self 

961 

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

963 self.Root = value 

964 

965 @readonly 

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

967 """ 

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

969 

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

971 """ 

972 return self._file 

973 

974 @readonly 

975 def Size(self) -> int: 

976 """ 

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

978 

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

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

981 """ 

982 if self._file is None: 

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

984 

985 return self._file._size 

986 

987 @readonly 

988 def Path(self) -> Path: 

989 """ 

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

991 

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

993 

994 :returns: Absolute path of the file. 

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

996 """ 

997 if self._parent is None: 

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

999 

1000 return self._parent.Path / self._name 

1001 

1002 def __hash__(self) -> int: 

1003 """ 

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

1005 

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

1007 object's identity and not from its name. 

1008 

1009 :returns: Hash of this filesystem element. 

1010 """ 

1011 return hash(id(self)) 

1012 

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

1014 """ 

1015 Copy this filename into another filesystem statistics scope. 

1016 

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

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

1019 

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

1021 :returns: The copied filename. 

1022 """ 

1023 fileID = self._file._id 

1024 

1025 if fileID in parent._root._ids: 

1026 file = parent._root._ids[fileID] 

1027 else: 

1028 fileSize = self._file._size 

1029 file = File(fileID, fileSize) 

1030 

1031 parent._root._ids[fileID] = file 

1032 

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

1034 

1035 def ToTree(self) -> Node: 

1036 """ 

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

1038 

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

1040 """ 

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

1042 """ 

1043 Nested function rendering a tree node as one line. 

1044 

1045 :param node: The tree node to render. 

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

1047 """ 

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

1049 

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

1051 value=self, 

1052 keyValuePairs={ 

1053 "kind": NodeKind.File, 

1054 "size": self._size 

1055 }, 

1056 format=format 

1057 ) 

1058 

1059 return fileNode 

1060 

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

1062 """ 

1063 Compare two Filename instances for equality. 

1064 

1065 :param other: Parameter to compare against. 

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

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

1068 """ 

1069 if not isinstance(other, Filename): 

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

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

1072 raise ex 

1073 

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

1075 

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

1077 """ 

1078 Compare two Filename instances for inequality. 

1079 

1080 :param other: Parameter to compare against. 

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

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

1083 """ 

1084 if not isinstance(other, Filename): 

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

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

1087 raise ex 

1088 

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

1090 

1091 def __repr__(self) -> str: 

1092 """ 

1093 Return a detailed string representation of this filename. 

1094 

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

1096 """ 

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

1098 

1099 def __str__(self) -> str: 

1100 """ 

1101 Return a string representation of this filesystem element. 

1102 

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

1104 """ 

1105 return self._name 

1106 

1107 

1108@export 

1109class SymbolicLink(Element[Directory]): 

1110 """ 

1111 A symbolic link in the filesystem statistics scope. 

1112 

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

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

1115 """ 

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

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

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

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

1120 

1121 def __init__( 

1122 self, 

1123 name: str, 

1124 target: Path, 

1125 parent: Nullable[Directory] 

1126 ) -> None: 

1127 """ 

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

1129 

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

1131 broken or out of range. 

1132 

1133 :param name: Name of the symbolic link. 

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

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

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

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

1138 """ 

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

1140 

1141 if target is None: 

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

1143 elif not isinstance(target, Path): 

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

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

1146 raise ex 

1147 

1148 self._target = target 

1149 self._isConnected = False 

1150 self._isBroken = None 

1151 self._isOutOfRange = None 

1152 

1153 if parent is not None: 

1154 parent._symbolicLinks[name] = self 

1155 

1156 if parent._root is not None: 

1157 self._root = parent._root 

1158 

1159 @readonly 

1160 def Path(self) -> Path: 

1161 """ 

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

1163 

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

1165 

1166 :returns: Path of the symbolic link. 

1167 """ 

1168 return self._parent.Path / self._name 

1169 

1170 @readonly 

1171 def Target(self) -> Path: 

1172 """ 

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

1174 

1175 :returns: Target path of the symbolic link. 

1176 """ 

1177 return self._target 

1178 

1179 @readonly 

1180 def IsConnected(self) -> bool: 

1181 """ 

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

1183 

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

1185 """ 

1186 return self._isConnected 

1187 

1188 @readonly 

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

1190 """ 

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

1192 

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

1194 """ 

1195 return self._isBroken 

1196 

1197 @readonly 

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

1199 """ 

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

1201 

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

1203 """ 

1204 return self._isOutOfRange 

1205 

1206 def __hash__(self) -> int: 

1207 """ 

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

1209 

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

1211 object's identity and not from its name. 

1212 

1213 :returns: Hash of this filesystem element. 

1214 """ 

1215 return hash(id(self)) 

1216 

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

1218 """ 

1219 Copy this symbolic link into another filesystem statistics scope. 

1220 

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

1222 :returns: The copied symbolic link, unresolved. 

1223 """ 

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

1225 

1226 def ToTree(self) -> Node: 

1227 """ 

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

1229 

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

1231 """ 

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

1233 """ 

1234 Nested function rendering a tree node as one line. 

1235 

1236 :param node: The tree node to render. 

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

1238 """ 

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

1240 

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

1242 value=self, 

1243 keyValuePairs={ 

1244 "kind": NodeKind.SymbolicLink, 

1245 "size": self._size 

1246 }, 

1247 format=format 

1248 ) 

1249 

1250 return symbolicLinkNode 

1251 

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

1253 """ 

1254 Compare two SymbolicLink instances for equality. 

1255 

1256 :param other: Parameter to compare against. 

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

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

1259 """ 

1260 if not isinstance(other, SymbolicLink): 

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

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

1263 raise ex 

1264 

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

1266 

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

1268 """ 

1269 Compare two SymbolicLink instances for inequality. 

1270 

1271 :param other: Parameter to compare against. 

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

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

1274 """ 

1275 if not isinstance(other, SymbolicLink): 

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

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

1278 raise ex 

1279 

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

1281 

1282 def __repr__(self) -> str: 

1283 """ 

1284 Return a detailed string representation of this symbolic link. 

1285 

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

1287 """ 

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

1289 

1290 def __str__(self) -> str: 

1291 """ 

1292 Return a string representation of this filesystem element. 

1293 

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

1295 """ 

1296 return self._name 

1297 

1298 

1299@export 

1300class Root(Directory): 

1301 """ 

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

1303 """ 

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

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

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

1307 

1308 def __init__( 

1309 self, 

1310 rootDirectory: Path, 

1311 collectSubdirectories: bool = True 

1312 ) -> None: 

1313 """ 

1314 Initialize a filesystem statistics scope for the given directory. 

1315 

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

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

1318 

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

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

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

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

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

1324 """ 

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

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

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

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

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

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

1331 

1332 self._ids = {} 

1333 self._brokenSymbolicLinks = [] 

1334 self._unconnectedSymbolicLinks = [] 

1335 

1336 super().__init__(rootDirectory.name) 

1337 self._root = self 

1338 self._path = rootDirectory 

1339 

1340 if collectSubdirectories: 

1341 self.CollectSubdirectories() 

1342 self.ResolveSymbolicLinks() 

1343 

1344 @readonly 

1345 def Path(self) -> Path: 

1346 """ 

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

1348 

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

1350 """ 

1351 return self._path 

1352 

1353 @readonly 

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

1355 """ 

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

1357 

1358 :returns: List of broken symbolic links. 

1359 """ 

1360 return self._brokenSymbolicLinks 

1361 

1362 @readonly 

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

1364 """ 

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

1366 

1367 :returns: List of unconnected symbolic links. 

1368 """ 

1369 return self._unconnectedSymbolicLinks 

1370 

1371 @readonly 

1372 def TotalHardLinkCount(self) -> int: 

1373 """ 

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

1375 

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

1377 directory entries. 

1378 

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

1380 """ 

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

1382 

1383 @readonly 

1384 def TotalHardLinkCount2(self) -> int: 

1385 """ 

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

1387 

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

1389 many directory entries reference it. 

1390 

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

1392 """ 

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

1394 

1395 @readonly 

1396 def TotalHardLinkCount3(self) -> int: 

1397 """ 

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

1399 

1400 .. attention:: 

1401 

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

1403 

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

1405 """ 

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

1407 

1408 @readonly 

1409 def Size2(self) -> int: 

1410 """ 

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

1412 

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

1414 """ 

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

1416 

1417 @readonly 

1418 def Size3(self) -> int: 

1419 """ 

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

1421 

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

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

1424 

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

1426 """ 

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

1428 

1429 @readonly 

1430 def TotalUniqueFileCount(self) -> int: 

1431 """ 

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

1433 

1434 :returns: Number of unique files. 

1435 """ 

1436 return len(self._ids) 

1437 

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

1439 """ 

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

1441 

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

1443 """ 

1444 symLink._isBroken = True 

1445 self._brokenSymbolicLinks.append(symLink) 

1446 

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

1448 """ 

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

1450 

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

1452 """ 

1453 symLink._isOutOfRange = True 

1454 self._unconnectedSymbolicLinks.append(symLink) 

1455 

1456 def Copy(self) -> Root: 

1457 """ 

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

1459 

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

1461 

1462 .. hint:: 

1463 

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

1465 There is no rescan or repeated aggregation needed. 

1466 

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

1468 """ 

1469 with Stopwatch() as sw: 

1470 root = Root(self._path, False) 

1471 root._size = self._size 

1472 

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

1474 subdir.Copy(root) 

1475 

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

1477 file.Copy(root) 

1478 

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

1480 link.Copy(root) 

1481 

1482 root._scanDuration = sw.Duration 

1483 root._aggregateDuration = 0.0 

1484 

1485 return root 

1486 

1487 def __repr__(self) -> str: 

1488 """ 

1489 Return a detailed string representation of this filesystem root. 

1490 

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

1492 """ 

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

1494 

1495 def __str__(self) -> str: 

1496 """ 

1497 Return a string representation of this filesystem element. 

1498 

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

1500 """ 

1501 return self._name 

1502 

1503 

1504@export 

1505class File(Base): 

1506 """ 

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

1508 

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

1510 """ 

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

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

1513 

1514 def __init__( 

1515 self, 

1516 id: int, 

1517 size: int, 

1518 parent: Nullable[Filename] = None 

1519 ) -> None: 

1520 """ 

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

1522 

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

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

1525 :param parent: Optional, parent reference. 

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

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

1528 """ 

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

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

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

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

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

1534 raise ex 

1535 

1536 self._id = id 

1537 

1538 if parent is None: 

1539 super().__init__(size, None) 

1540 self._parents = [] 

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

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

1543 self._parents = [parent] 

1544 parent._file = self 

1545 else: 

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

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

1548 raise ex 

1549 

1550 @readonly 

1551 def ID(self) -> int: 

1552 """ 

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

1554 

1555 :returns: Unique file object identifier. 

1556 """ 

1557 return self._id 

1558 

1559 @readonly 

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

1561 """ 

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

1563 

1564 .. hint:: 

1565 

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

1567 

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

1569 """ 

1570 return self._parents 

1571 

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

1573 """ 

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

1575 

1576 :param filename: Reference to a filename object. 

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

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

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

1580 """ 

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

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

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

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

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

1586 raise ex 

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

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

1589 

1590 self._parents.append(filename) 

1591 filename._file = self 

1592 

1593 if filename._root is not None: 

1594 self._root = filename._root