Coverage for pyTooling/Dependency/__init__.py: 81%

355 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-03 23: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""" 

32Implementation of package dependencies. 

33 

34.. hint:: 

35 

36 See :ref:`high-level help <DEPENDENCIES>` for explanations and usage examples. 

37 

38.. seealso:: 

39 

40 :mod:`pyTooling.Dependency.Python` 

41 |rarr| The implementation for Python packages on a package index. 

42 :mod:`pyTooling.Versioning` 

43 |rarr| The version numbers a requirement is resolved against. 

44 :mod:`pyTooling.Graph` 

45 |rarr| The graph data structure a dependency graph is built on. 

46""" 

47from __future__ import annotations 

48 

49from datetime import datetime 

50from typing import Optional as Nullable, Iterable, Self, Iterator 

51 

52from pyTooling.Decorators import export, readonly 

53from pyTooling.MetaClasses import ExtendedType 

54from pyTooling.Exceptions import ToolingException 

55from pyTooling.Common import getFullyQualifiedName, firstKey, firstValue 

56from pyTooling.GenericPath.URL import URL 

57from pyTooling.Licensing import LicenseExpression, BaseLicense, UnknownLicense 

58from pyTooling.Versioning import SemanticVersion 

59from pyTooling.Warning import Warning 

60 

61 

62@export 

63class DependencyError(ToolingException): 

64 """Base-exception of all exceptions raised by :mod:`pyTooling.Dependency`.""" 

65 

66 

67@export 

68class NoSessionAvailableError(DependencyError): 

69 """ 

70 The operation needs a session to the package index, but no session was opened. 

71 

72 A session is created by the package index and handed to the objects it creates. 

73 """ 

74 

75 

76@export 

77class RequirementsFileNotFoundError(DependencyError): 

78 """A requirements file doesn't exist, or a ``-r`` line references one that doesn't.""" 

79 

80 

81@export 

82class CircularRequirementsFileError(DependencyError): 

83 """ 

84 A ``-r`` line references a requirements file that is already being read further up the include chain. 

85 

86 A cycle isn't read once and ignored: a file including itself is a statement nobody wrote on purpose, and reading 

87 it silently would hide it. The exception names the chain, so the ``-r`` line to delete is in the message. 

88 """ 

89 

90 

91@export 

92class ProjectNotFoundError(DependencyError): 

93 """The package index doesn't know a project of that name.""" 

94 

95 

96@export 

97class ReleaseNotFoundError(DependencyError): 

98 """The project exists in the package index, but not in the requested version.""" 

99 

100 

101@export 

102class BrokenRequirementWarning(Warning): 

103 """ 

104 A requirement names an extra the project doesn't declare. 

105 

106 Such a requirement can't be assigned to an extra, so it's not reachable through :attr:`Requirements`. 

107 """ 

108 

109 

110@export 

111class UnknownLicenseWarning(Warning): 

112 """ 

113 A package version's license couldn't be resolved from what its package index publishes. 

114 

115 The published expression is kept in :attr:`~PackageVersion.LicenseExpression` either way, so the warning names 

116 what would have to be stated by hand. 

117 """ 

118 

119 

120@export 

121class ReleaseDetailsWarning(Warning): 

122 """Downloading the details of a release failed, therefore the release was dropped from the project.""" 

123 

124 

125@export 

126class PackageVersion(metaclass=ExtendedType, slots=True): 

127 """ 

128 The package's version of a :class:`Package`. 

129 

130 A :class:`Package` has multiple available versions. A version can have multiple dependencies to other 

131 :class:`PackageVersion`s. 

132 """ 

133 

134 _package: Package #: Reference to the corresponding package 

135 _version: SemanticVersion #: :class:`SemanticVersion` of this version. 

136 _releasedAt: Nullable[datetime] #: Time this package version was released. 

137 _licenseExpression: LicenseExpression #: What was published about the license. 

138 _licenseURL: Nullable[URL] #: URL of the license's text, if known. 

139 _repositoryURL: Nullable[URL] #: URL of the source repository, if known. 

140 _documentationURL: Nullable[URL] #: URL of the documentation, if known. 

141 _issueTrackerURL: Nullable[URL] #: URL of the issue tracker, if known. 

142 _projectURL: Nullable[URL] #: URL of the project's homepage. 

143 _changelogURL: Nullable[URL] #: URL of the changelog, if known. 

144 _dependsOn: dict[Package, dict[SemanticVersion, PackageVersion]] #: Versioned dependencies to other packages. 

145 

146 def __init__(self, version: SemanticVersion, package: Package, releasedAt: Nullable[datetime] = None) -> None: 

147 """ 

148 Initializes a package version. 

149 

150 :param version: Semantic version of this package. 

151 :param package: Package this version is associated to. 

152 :param releasedAt: Optional, release date and time. 

153 :raises TypeError: When parameter 'version' is not of type :class:`SemanticVersion`. 

154 :raises TypeError: When parameter 'package' is not of type :class:`Package`. 

155 :raises TypeError: When parameter 'releasedAt' is not of type :class:`~datetime.datetime`. 

156 :raises ToolingException: When version already exists for the associated package. 

157 """ 

158 if not isinstance(version, SemanticVersion): 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true

159 ex = TypeError("Parameter 'version' is not of type 'SemanticVersion'.") 

160 ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.") 

161 raise ex 

162 elif version in package._versions: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true

163 raise ToolingException(f"Version '{version}' is already registered in package '{package._name}'.") 

164 

165 self._version = version 

166 package._versions[version] = self 

167 

168 if not isinstance(package, Package): 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true

169 ex = TypeError("Parameter 'package' is not of type 'Package'.") 

170 ex.add_note(f"Got type '{getFullyQualifiedName(package)}'.") 

171 raise ex 

172 

173 self._package = package 

174 

175 if releasedAt is not None and not isinstance(releasedAt, datetime): 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true

176 ex = TypeError("Parameter 'releasedAt' is not of type 'datetime'.") 

177 ex.add_note(f"Got type '{getFullyQualifiedName(releasedAt)}'.") 

178 raise ex 

179 

180 self._releasedAt = releasedAt 

181 self._licenseExpression = UnknownLicense() 

182 self._licenseURL = None 

183 self._repositoryURL = None 

184 self._documentationURL = None 

185 self._issueTrackerURL = None 

186 self._projectURL = None 

187 self._changelogURL = None 

188 self._dependsOn = {} 

189 

190 @readonly 

191 def Package(self) -> Package: 

192 """ 

193 Read-only property to access the associated package. 

194 

195 :returns: Associated package. 

196 """ 

197 return self._package 

198 

199 @readonly 

200 def Version(self) -> SemanticVersion: 

201 """ 

202 Read-only property to access the semantic version of a package. 

203 

204 :returns: Semantic version of a package. 

205 """ 

206 return self._version 

207 

208 @readonly 

209 def ReleasedAt(self) -> Nullable[datetime]: 

210 """ 

211 Read-only property to access the release date and time. 

212 

213 :returns: Optional release date and time. 

214 """ 

215 return self._releasedAt 

216 

217 @readonly 

218 def Licenses(self) -> tuple[BaseLicense, ...]: 

219 """ 

220 Read-only property to return the licenses named by :attr:`LicenseExpression`. 

221 

222 **Every** license the version is published under, whether or not SPDX knows it: an 

223 :class:`~pyTooling.Licensing.SPDXLicense` for one on the SPDX License List, a 

224 :class:`~pyTooling.Licensing.LicenseReference` for a ``LicenseRef-<id>`` that isn't. Both are a 

225 :class:`~pyTooling.Licensing.BaseLicense` and both answer 

226 :attr:`~pyTooling.Licensing.BaseLicense.Identifier`, so a report doesn't have to branch: 

227 

228 .. code-block:: python 

229 

230 [term.Identifier for term in version.Licenses] # ['MIT', 'LicenseRef-Proprietary'] 

231 

232 A :class:`~pyTooling.Licensing.License` object exists only for the first kind, and 

233 :attr:`~pyTooling.Licensing.SPDXLicense.License` is where it is reached. 

234 

235 ``NOASSERTION`` and ``NONE`` are reported here too, as an 

236 :class:`~pyTooling.Licensing.UnknownLicense` - the index *stated* something, and dropping it would leave this 

237 indistinguishable from a version whose license didn't resolve at all. Test for that class where the 

238 difference matters; :attr:`LicenseExpression` is ``None`` only when nothing resolved. 

239 

240 This flattens the expression: ``Apache-2.0 AND MIT`` and ``Apache-2.0 OR BSD-2-Clause`` both give two 

241 licenses, although one requires both and the other offers a choice. Ask :attr:`LicenseExpression` when that 

242 difference matters. A license exception is not a license and is not returned. 

243 

244 :returns: Licenses this version is published under, or an empty tuple if nothing resolved. 

245 """ 

246 return tuple( 

247 node 

248 for node in self._licenseExpression.IterateExpression() 

249 if isinstance(node, BaseLicense) 

250 ) 

251 

252 @readonly 

253 def LicenseExpression(self) -> LicenseExpression: 

254 """ 

255 Read-only property to access the license expression (:attr:`_licenseExpression`). 

256 

257 The expression keeps what :attr:`Licenses` flattens away - which licenses are required together and which are 

258 a choice. 

259 

260 **It is never** ``None``. A version whose license didn't resolve carries an 

261 :class:`~pyTooling.Licensing.UnknownLicense`, which is SPDX's own way of saying so, and that node keeps what 

262 was published. Test for that class rather than for ``None``. 

263 

264 :returns: The license expression. 

265 """ 

266 return self._licenseExpression 

267 

268 @readonly 

269 def PublishedLicense(self) -> str: 

270 """ 

271 Read-only property to access the license as it was published. 

272 

273 This is the expression's :attr:`~pyTooling.Licensing.LicenseExpression.OriginalText`, which is the **only** 

274 place the published text is kept - a parsed expression records what 

275 :meth:`~pyTooling.Licensing.LicenseExpression.Parse` read, and a node built because nothing parsed records 

276 what was stated anyway. :meth:`~pyTooling.Licensing.LicenseExpression.__str__` is not a substitute - it 

277 re-renders canonically, so ``Apache-2.0 or MIT`` comes back as ``Apache-2.0 OR MIT``. 

278 

279 :returns: The license as published, or an empty string if nothing was published. 

280 """ 

281 return self._licenseExpression.OriginalText 

282 

283 @readonly 

284 def LicenseURL(self) -> Nullable[URL]: 

285 """ 

286 Read-only property to access the URL of this version's license text (:attr:`_licenseURL`). 

287 

288 A package index has no field for it, so this is only known where it was stated by hand. 

289 

290 :returns: URL of the license's text, or ``None`` if unknown. 

291 """ 

292 return self._licenseURL 

293 

294 @readonly 

295 def RepositoryURL(self) -> Nullable[URL]: 

296 """ 

297 Read-only property to access the URL of the source repository (:attr:`_repositoryURL`). 

298 

299 This is where the sources live, which is not where the package is *published* - a package index has its own 

300 page per package. 

301 

302 A package index publishes these per release, and they move - a project migrating to another forge has one 

303 URL before the migration and another after it. 

304 

305 :returns: URL of the source repository, or ``None`` if this release didn't name one. 

306 """ 

307 return self._repositoryURL 

308 

309 @readonly 

310 def DocumentationURL(self) -> Nullable[URL]: 

311 """ 

312 Read-only property to access the URL of the documentation (:attr:`_documentationURL`). 

313 

314 A package index publishes these per release, and they move - a project migrating to another forge has one 

315 URL before the migration and another after it. 

316 

317 :returns: URL of the documentation, or ``None`` if this release didn't name one. 

318 """ 

319 return self._documentationURL 

320 

321 @readonly 

322 def IssueTrackerURL(self) -> Nullable[URL]: 

323 """ 

324 Read-only property to access the URL of the issue tracker (:attr:`_issueTrackerURL`). 

325 

326 A package index publishes these per release, and they move - a project migrating to another forge has one 

327 URL before the migration and another after it. 

328 

329 :returns: URL of the issue tracker, or ``None`` if this release didn't name one. 

330 """ 

331 return self._issueTrackerURL 

332 

333 @readonly 

334 def ProjectURL(self) -> Nullable[URL]: 

335 """ 

336 Read-only property to access the URL of the project's homepage (:attr:`_projectURL`). 

337 

338 A package index publishes these per release, and they move - a project migrating to another forge has one 

339 URL before the migration and another after it. 

340 

341 :returns: URL of the project's homepage, or ``None`` if this release didn't name one. 

342 """ 

343 return self._projectURL 

344 

345 @readonly 

346 def ChangelogURL(self) -> Nullable[URL]: 

347 """ 

348 Read-only property to access the URL of the changelog (:attr:`_changelogURL`). 

349 

350 A package index publishes these per release, and they move - a project migrating to another forge has one 

351 URL before the migration and another after it. 

352 

353 :returns: URL of the changelog, or ``None`` if this release didn't name one. 

354 """ 

355 return self._changelogURL 

356 

357 @readonly 

358 def DependsOn(self) -> dict[Package, dict[SemanticVersion, PackageVersion]]: 

359 """ 

360 Read-only property to access the dictionary of dictionaries referencing dependencies. 

361 

362 The outer dictionary key groups dependencies by :class:`Package`. |br| 

363 The inner dictionary key accesses dependencies by :class:`~pyTooling.Versioning.SemanticVersion`. 

364 

365 :returns: Dictionary of dependencies. 

366 """ 

367 return self._dependsOn 

368 

369 def AddDependencyToPackageVersion(self, packageVersion: PackageVersion) -> None: 

370 """ 

371 Add a dependency from current package version to another package version. 

372 

373 :param packageVersion: Dependency to be added. 

374 """ 

375 if (package := packageVersion._package) in self._dependsOn: 

376 pack = self._dependsOn[package] 

377 if (version := packageVersion._version) in pack: 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true

378 pass 

379 else: 

380 pack[version] = packageVersion 

381 else: 

382 self._dependsOn[package] = {packageVersion._version: packageVersion} 

383 

384 def AddDependencyToPackageVersions(self, packageVersions: Iterable[PackageVersion]) -> None: 

385 """ 

386 Add multiple dependencies from current package version to a list of other package versions. 

387 

388 :param packageVersions: Dependencies to be added. 

389 """ 

390 # TODO: check for iterable 

391 

392 for packageVersion in packageVersions: 

393 if (package := packageVersion._package) in self._dependsOn: 

394 pack = self._dependsOn[package] 

395 if (version := packageVersion._version) in pack: 395 ↛ 396line 395 didn't jump to line 396 because the condition on line 395 was never true

396 pass 

397 else: 

398 pack[version] = packageVersion 

399 else: 

400 self._dependsOn[package] = {packageVersion._version: packageVersion} 

401 

402 def AddDependencyTo( 

403 self, 

404 package: str | Package, 

405 version: str | SemanticVersion | Iterable[str | SemanticVersion] 

406 ) -> None: 

407 """ 

408 Add a dependency from current package version to another package version. 

409 

410 :param package: :class:`Package` object or name of the package. 

411 :param version: :class:`~pyTooling.Versioning.SemanticVersion` object or version string or an iterable thereof. 

412 :raises TypeError: If parameter 'package' is not of type :class:`Package`. 

413 """ 

414 if isinstance(package, str): 

415 package = self._package._storage._packages[package] 

416 elif not isinstance(package, Package): 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true

417 ex = TypeError("Parameter 'package' is not of type 'str' nor 'Package'.") 

418 ex.add_note(f"Got type '{getFullyQualifiedName(package)}'.") 

419 raise ex 

420 

421 if isinstance(version, str): 

422 version = SemanticVersion.Parse(version) 

423 elif isinstance(version, Iterable): 

424 for v in version: 

425 if isinstance(v, str): 425 ↛ 427line 425 didn't jump to line 427 because the condition on line 425 was always true

426 v = SemanticVersion.Parse(v) 

427 elif not isinstance(v, SemanticVersion): 

428 ex = TypeError("Parameter 'version' contains an element, which is not of type 'str' nor 'SemanticVersion'.") 

429 ex.add_note(f"Got type '{getFullyQualifiedName(v)}'.") 

430 raise ex# 

431 

432 packageVersion = package._versions[v] 

433 self.AddDependencyToPackageVersion(packageVersion) 

434 

435 return 

436 elif not isinstance(version, SemanticVersion): 436 ↛ 437line 436 didn't jump to line 437 because the condition on line 436 was never true

437 ex = TypeError("Parameter 'version' is not of type 'str' nor 'SemanticVersion'.") 

438 ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.") 

439 raise ex 

440 

441 packageVersion = package._versions[version] 

442 self.AddDependencyToPackageVersion(packageVersion) 

443 

444 def SortDependencies(self) -> Self: 

445 """ 

446 Sort versions of a package and dependencies by version, thus dependency resolution can work on pre-sorted lists and 

447 dictionaries. 

448 

449 :returns: The instance itself (for method-chaining). 

450 """ 

451 for package, versions in self._dependsOn.items(): 

452 self._dependsOn[package] = {version: versions[version] for version in sorted(versions.keys(), reverse=True)} 

453 return self 

454 

455 def SolveLatest(self) -> Iterable[PackageVersion]: 

456 """ 

457 Solve the dependency problem, while using preferably latest versions. 

458 

459 .. todo:: 

460 

461 Describe algorithm. 

462 

463 :returns: A list of :class:`PackageVersion`s fulfilling the constraints of the dependency problem. 

464 :raises ToolingException: When there is no valid solution to the problem. 

465 """ 

466 solution: dict[Package, PackageVersion] = {self._package: self} 

467 

468 def _recursion(currentSolution: dict[Package, PackageVersion]) -> bool: 

469 """ 

470 Nested function for recursion. 

471 

472 It adds the latest matching version of every package the current solution requires, and recurses until no 

473 package is missing. 

474 

475 :param currentSolution: The packages selected so far, by package. 

476 :returns: ``True``, if the solution is complete and consistent. 

477 """ 

478 # 1. Identify all required packages based on current selection 

479 requiredPackages: set[Package] = set() 

480 for packageVersion in currentSolution.values(): 

481 requiredPackages.update(packageVersion.DependsOn.keys()) 

482 

483 # 2. Identify which required packages are missing from the solution 

484 missingPackages = requiredPackages - currentSolution.keys() 

485 

486 # Base Case: If no packages are missing, the graph is complete and valid 

487 if len(missingPackages) == 0: 

488 return True 

489 

490 # 3. Pick the next package to resolve 

491 # (Heuristic: we just pick the first one, but could be optimized) 

492 targetPackage = next(iter(missingPackages)) 

493 

494 # 4. Determine valid candidates 

495 # The candidate version must satisfy the constraints of all parents currently in the solution 

496 allowedVersions: Nullable[set[SemanticVersion]] = None 

497 

498 for parentPackageVersion in currentSolution.values(): 

499 if targetPackage in parentPackageVersion.DependsOn: 

500 # Get the set of versions allowed by this specific parent 

501 # (Keys of the inner dict are SemanticVersion objects) 

502 parentConstraints = set(parentPackageVersion.DependsOn[targetPackage].keys()) 

503 

504 if allowedVersions is None: 

505 allowedVersions = parentConstraints 

506 else: 

507 # Intersect with existing constraints (must satisfy everyone) 

508 allowedVersions &= parentConstraints 

509 

510 # If the intersection is empty, no version satisfies all parents -> backtrack 

511 if not allowedVersions: 

512 return False 

513 

514 # 5. Try candidates (sorted descending to prioritize latest) 

515 # We convert the set to a list and sort it reverse 

516 for version_key in sorted(list(allowedVersions), reverse=True): 

517 candidate = targetPackage.Versions[version_key] 

518 

519 # 6. Check compatibility (reverse dependencies) 

520 # Does the candidate depend on anything we have already selected? 

521 # If so, does the candidate accept the version we already picked? 

522 isCompatible = True 

523 for existingPackage, existingPackageVersion in currentSolution.items(): 

524 if existingPackage in candidate.DependsOn: 

525 # If candidate relies on 'existingPackage', check if 'existingPackageVersion' is in the allowed list 

526 if existingPackageVersion._version not in candidate.DependsOn[existingPackage]: 

527 isCompatible = False 

528 break 

529 

530 if isCompatible: 

531 # Tentatively add to solution 

532 currentSolution[targetPackage] = candidate 

533 

534 # Recurse 

535 if _recursion(currentSolution): 

536 return True 

537 

538 # If recursion failed, remove (backtrack) and try next version 

539 del currentSolution[targetPackage] 

540 

541 # If we run out of versions for this package, this path is dead 

542 return False 

543 

544 # Run the solver 

545 if _recursion(solution): 

546 return list(solution.values()) 

547 else: 

548 raise ToolingException(f"Could not resolve dependencies for '{self}'.") 

549 

550 def __len__(self) -> int: 

551 """ 

552 Returns the number of dependencies. 

553 

554 :returns: Number of dependencies. 

555 """ 

556 return len(self._dependsOn) 

557 

558 def __str__(self) -> str: 

559 """ 

560 Return a string representation of this package version. 

561 

562 :returns: The package's name and version. 

563 """ 

564 return f"{self._package._name} - {self._version}" 

565 

566 

567@export 

568class Package(metaclass=ExtendedType, slots=True): 

569 """ 

570 The package, which exists in multiple versions (:class:`PackageVersion`). 

571 """ 

572 _storage: PackageStorage #: Reference to the package's storage. 

573 _name: str #: Name of the package. 

574 _versions: dict[SemanticVersion, PackageVersion] #: A dictionary of available versions for this package. 

575 

576 def __init__(self, name: str, *, storage: PackageStorage) -> None: 

577 """ 

578 Initializes a package. 

579 

580 :param name: Name of the package. 

581 :param storage: The package's storage. 

582 :raises TypeError: If a parameter is not of the expected type. 

583 """ 

584 if not isinstance(name, str): 584 ↛ 585line 584 didn't jump to line 585 because the condition on line 584 was never true

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

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

587 raise ex 

588 

589 self._name = name 

590 

591 if not isinstance(storage, PackageStorage): 591 ↛ 592line 591 didn't jump to line 592 because the condition on line 591 was never true

592 ex = TypeError("Parameter 'storage' is not of type 'PackageStorage'.") 

593 ex.add_note(f"Got type '{getFullyQualifiedName(storage)}'.") 

594 raise ex 

595 

596 self._storage = storage 

597 storage._packages[name] = self 

598 

599 self._versions = {} 

600 

601 @readonly 

602 def Storage(self) -> PackageStorage: 

603 """ 

604 Read-only property to access the package's storage. 

605 

606 :returns: Package storage. 

607 """ 

608 return self._storage 

609 

610 @readonly 

611 def Name(self) -> str: 

612 """ 

613 Read-only property to access the package name. 

614 

615 :returns: Name of the package. 

616 """ 

617 return self._name 

618 

619 @readonly 

620 def LatestVersion(self) -> Nullable[PackageVersion]: 

621 """ 

622 Read-only property to return the most recent version of this package. 

623 

624 Versions are held newest-first once :meth:`SortVersions` has run, so this is the first of them. 

625 

626 :returns: The latest version, or ``None`` if the package has no version yet. 

627 """ 

628 if len(self._versions) == 0: 

629 return None 

630 

631 return firstValue(self._versions) 

632 

633 @readonly 

634 def RepositoryURL(self) -> Nullable[URL]: 

635 """ 

636 Read-only property to return the URL of the source repository, as the latest version states it. 

637 

638 The URL belongs to a :class:`PackageVersion` because it moves over a project's life; this mirror answers what 

639 is true *now*, which is what a reader of the package usually wants. 

640 

641 :returns: URL of the source repository of :attr:`LatestVersion`, or ``None`` if unknown or if 

642 the package has no version. 

643 """ 

644 if (latest := self.LatestVersion) is None: 

645 return None 

646 

647 return latest.RepositoryURL 

648 

649 @readonly 

650 def DocumentationURL(self) -> Nullable[URL]: 

651 """ 

652 Read-only property to return the URL of the documentation, as the latest version states it. 

653 

654 The URL belongs to a :class:`PackageVersion` because it moves over a project's life; this mirror answers what 

655 is true *now*, which is what a reader of the package usually wants. 

656 

657 :returns: URL of the documentation of :attr:`LatestVersion`, or ``None`` if unknown or the package has no version. 

658 """ 

659 if (latest := self.LatestVersion) is None: 

660 return None 

661 

662 return latest.DocumentationURL 

663 

664 @readonly 

665 def IssueTrackerURL(self) -> Nullable[URL]: 

666 """ 

667 Read-only property to return the URL of the issue tracker, as the latest version states it. 

668 

669 The URL belongs to a :class:`PackageVersion` because it moves over a project's life; this mirror answers what 

670 is true *now*, which is what a reader of the package usually wants. 

671 

672 :returns: URL of the issue tracker of :attr:`LatestVersion`, or ``None`` if unknown or the package has no version. 

673 """ 

674 if (latest := self.LatestVersion) is None: 

675 return None 

676 

677 return latest.IssueTrackerURL 

678 

679 @readonly 

680 def ProjectURL(self) -> Nullable[URL]: 

681 """ 

682 Read-only property to return the URL of the project's homepage, as the latest version states it. 

683 

684 The URL belongs to a :class:`PackageVersion` because it moves over a project's life; this mirror answers what 

685 is true *now*, which is what a reader of the package usually wants. 

686 

687 :returns: URL of the project's homepage of :attr:`LatestVersion`, or ``None`` if unknown or if 

688 the package has no version. 

689 """ 

690 if (latest := self.LatestVersion) is None: 

691 return None 

692 

693 return latest.ProjectURL 

694 

695 @readonly 

696 def ChangelogURL(self) -> Nullable[URL]: 

697 """ 

698 Read-only property to return the URL of the changelog, as the latest version states it. 

699 

700 The URL belongs to a :class:`PackageVersion` because it moves over a project's life; this mirror answers what 

701 is true *now*, which is what a reader of the package usually wants. 

702 

703 :returns: URL of the changelog of :attr:`LatestVersion`, or ``None`` if unknown or the package has no version. 

704 """ 

705 if (latest := self.LatestVersion) is None: 

706 return None 

707 

708 return latest.ChangelogURL 

709 

710 @readonly 

711 def Versions(self) -> dict[SemanticVersion, PackageVersion]: 

712 """ 

713 Read-only property to access the dictionary of available versions. 

714 

715 :returns: Available version dictionary. 

716 """ 

717 return self._versions 

718 

719 @readonly 

720 def VersionCount(self) -> int: 

721 """ 

722 Read-only property to return the number of versions this package has. 

723 

724 :returns: Number of versions. 

725 """ 

726 return len(self._versions) 

727 

728 def SortVersions(self) -> None: 

729 """ 

730 Sort versions within this package in reverse order (latest first). 

731 """ 

732 self._versions = {k: self._versions[k].SortDependencies() for k in sorted(self._versions.keys(), reverse=True)} 

733 

734 def __len__(self) -> int: 

735 """ 

736 Returns the number of available versions. 

737 

738 :returns: Number of versions. 

739 """ 

740 return len(self._versions) 

741 

742 def __iter__(self) -> Iterator[PackageVersion]: 

743 """ 

744 Iterate the versions of this package. 

745 

746 :returns: An iterator over all versions of this package. 

747 """ 

748 return iter(self._versions.values()) 

749 

750 def __getitem__(self, version: str | SemanticVersion) -> PackageVersion: 

751 """ 

752 Access a package version in the package by version string or semantic version. 

753 

754 :param version: Version as string or instance. 

755 :returns: The package version. 

756 :raises KeyError: If version is not available for the package. 

757 :raises TypeError: If the given key is not of the expected type. 

758 """ 

759 if isinstance(version, str): 759 ↛ 761line 759 didn't jump to line 761 because the condition on line 759 was always true

760 version = SemanticVersion.Parse(version) 

761 elif not isinstance(version, SemanticVersion): 

762 ex = TypeError("Parameter 'version' is neither a 'str' nor of type 'SemanticVersion'.") 

763 ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.") 

764 raise ex 

765 

766 return self._versions[version] 

767 

768 def __str__(self) -> str: 

769 """ 

770 Return a string representation of this package. 

771 

772 :returns: The package's name and latest version. 

773 """ 

774 if len(self._versions) == 0: 

775 return f"{self._name} (empty)" 

776 else: 

777 return f"{self._name} (latest: {firstKey(self._versions)})" 

778 

779 

780@export 

781class PackageStorage(metaclass=ExtendedType, slots=True): 

782 """ 

783 A storage for packages. 

784 """ 

785 _graph: PackageDependencyGraph #: Reference to the overall dependency graph data structure. 

786 _name: str #: Package dependency graph name 

787 _packages: dict[str, Package] #: Dictionary of known packages. 

788 

789 def __init__(self, name: str, graph: PackageDependencyGraph) -> None: 

790 """ 

791 Initializes the package storage. 

792 

793 :param name: Name of the package storage. 

794 :param graph: PackageDependencyGraph instance (parent). 

795 :raises TypeError: If a parameter is not of the expected type. 

796 """ 

797 if not isinstance(name, str): 797 ↛ 798line 797 didn't jump to line 798 because the condition on line 797 was never true

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

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

800 raise ex 

801 

802 self._name = name 

803 

804 if not isinstance(graph, PackageDependencyGraph): 804 ↛ 805line 804 didn't jump to line 805 because the condition on line 804 was never true

805 ex = TypeError("Parameter 'graph' is not of type 'PackageDependencyGraph'.") 

806 ex.add_note(f"Got type '{getFullyQualifiedName(graph)}'.") 

807 raise ex 

808 

809 self._graph = graph 

810 graph._storages[name] = self 

811 

812 self._packages = {} 

813 

814 @readonly 

815 def Graph(self) -> PackageDependencyGraph: 

816 """ 

817 Read-only property to access the package dependency graph. 

818 

819 :returns: Package dependency graph. 

820 """ 

821 return self._graph 

822 

823 @readonly 

824 def Name(self) -> str: 

825 """ 

826 Read-only property to access the package dependency graph's name. 

827 

828 :returns: Name of the package dependency graph. 

829 """ 

830 return self._name 

831 

832 @readonly 

833 def Packages(self) -> dict[str, Package]: 

834 """ 

835 Read-only property to access the dictionary of known packages. 

836 

837 :returns: Known packages dictionary. 

838 """ 

839 return self._packages 

840 

841 @readonly 

842 def PackageCount(self) -> int: 

843 """ 

844 Read-only property to return the number of packages in this storage. 

845 

846 :returns: Number of packages. 

847 """ 

848 return len(self._packages) 

849 

850 def CreatePackage(self, packageName: str) -> Package: 

851 """ 

852 Create a new package in the package dependency graph. 

853 

854 :param packageName: Name of the new package. 

855 :returns: New package's instance. 

856 """ 

857 return Package(packageName, storage=self) 

858 

859 def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]: 

860 """ 

861 Create multiple new packages in the package dependency graph. 

862 

863 :param packageNames: List of package names. 

864 :returns: List of new package instances. 

865 """ 

866 return [Package(packageName, storage=self) for packageName in packageNames] 

867 

868 def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion: 

869 """ 

870 Create a new package and a package version in the package dependency graph. 

871 

872 :param packageName: Name of the new package. 

873 :param version: Version string. 

874 :returns: New package version instance. 

875 """ 

876 package = Package(packageName, storage=self) 

877 return PackageVersion(SemanticVersion.Parse(version), package) 

878 

879 def CreatePackageVersions(self, packageName: str, versions: Iterable[str]) -> Iterable[PackageVersion]: 

880 """ 

881 Create a new package and multiple package versions in the package dependency graph. 

882 

883 :param packageName: Name of the new package. 

884 :param versions: List of version string.s 

885 :returns: List of new package version instances. 

886 """ 

887 package = Package(packageName, storage=self) 

888 return [PackageVersion(SemanticVersion.Parse(version), package) for version in versions] 

889 

890 def SortPackageVersions(self) -> None: 

891 """ 

892 Sort versions within all known packages in reverse order (latest first). 

893 """ 

894 for package in self._packages.values(): 

895 package.SortVersions() 

896 

897 def __len__(self) -> int: 

898 """ 

899 Returns the number of known packages. 

900 

901 :returns: Number of packages. 

902 """ 

903 return len(self._packages) 

904 

905 def __iter__(self) -> Iterator[Package]: 

906 """ 

907 Iterate the packages in this storage. 

908 

909 :returns: An iterator over all packages in this storage. 

910 """ 

911 return iter(self._packages.values()) 

912 

913 def __getitem__(self, name: str) -> Package: 

914 """ 

915 Access a known package in the package dependency graph by package name. 

916 

917 :param name: Name of the package. 

918 :returns: The package. 

919 :raises KeyError: If package is not known within the package dependency graph. 

920 """ 

921 return self._packages[name] 

922 

923 def __str__(self) -> str: 

924 """ 

925 Return a string representation of this graph. 

926 

927 :returns: The graph's name and number of known packages. 

928 """ 

929 if len(self._packages) == 0: 929 ↛ 932line 929 didn't jump to line 932 because the condition on line 929 was always true

930 return f"{self._name} (empty)" 

931 else: 

932 return f"{self._name} ({len(self._packages)})" 

933 

934 

935@export 

936class PackageDependencyGraph(metaclass=ExtendedType, slots=True): 

937 """ 

938 A package dependency graph collecting all known packages. 

939 """ 

940 _name: str #: Package dependency graph name 

941 _storages: dict[str, PackageStorage] #: Dictionary of known package storages. 

942 

943 def __init__(self, name: str) -> None: 

944 """ 

945 Initializes the package dependency graph. 

946 

947 :param name: Name of the dependency graph. 

948 :raises TypeError: If a parameter is not of the expected type. 

949 """ 

950 if not isinstance(name, str): 950 ↛ 951line 950 didn't jump to line 951 because the condition on line 950 was never true

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

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

953 raise ex 

954 

955 self._name = name 

956 

957 self._storages = {} 

958 

959 @readonly 

960 def Name(self) -> str: 

961 """ 

962 Read-only property to access the package dependency graph's name. 

963 

964 :returns: Name of the package dependency graph. 

965 """ 

966 return self._name 

967 

968 @readonly 

969 def Storages(self) -> dict[str, PackageStorage]: 

970 """ 

971 Read-only property to access the dictionary of known package storages. 

972 

973 :returns: Known package storage dictionary. 

974 """ 

975 return self._storages 

976 

977 # def CreatePackage(self, packageName: str) -> Package: 

978 # """ 

979 # Create a new package in the package dependency graph. 

980 # 

981 # :param packageName: Name of the new package. 

982 # :returns: New package's instance. 

983 # """ 

984 # return Package(packageName, storage=self) 

985 # 

986 # def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]: 

987 # """ 

988 # Create multiple new packages in the package dependency graph. 

989 # 

990 # :param packageNames: List of package names. 

991 # :returns: List of new package instances. 

992 # """ 

993 # return [Package(packageName, storage=self) for packageName in packageNames] 

994 # 

995 # def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion: 

996 # """ 

997 # Create a new package and a package version in the package dependency graph. 

998 # 

999 # :param packageName: Name of the new package. 

1000 # :param version: Version string. 

1001 # :returns: New package version instance. 

1002 # """ 

1003 # package = Package(packageName, storage=self) 

1004 # return PackageVersion(SemanticVersion.Parse(version), package) 

1005 # 

1006 # def CreatePackageVersions(self, packageName: str, versions: Iterable[str]) -> Iterable[PackageVersion]: 

1007 # """ 

1008 # Create a new package and multiple package versions in the package dependency graph. 

1009 # 

1010 # :param packageName: Name of the new package. 

1011 # :param versions: List of version string.s 

1012 # :returns: List of new package version instances. 

1013 # """ 

1014 # package = Package(packageName, storage=self) 

1015 # return [PackageVersion(SemanticVersion.Parse(version), package) for version in versions] 

1016 

1017 def SortPackageVersions(self) -> None: 

1018 """ 

1019 Sort versions within all known packages in reverse order (latest first). 

1020 """ 

1021 for storage in self._storages.values(): 

1022 storage.SortPackageVersions() 

1023 

1024 def __len__(self) -> int: 

1025 """ 

1026 Returns the number of known packages. 

1027 

1028 :returns: Number of packages. 

1029 """ 

1030 return len(self._storages) 

1031 

1032 def __iter__(self) -> Iterator[PackageStorage]: 

1033 """ 

1034 Iterate the storages in this dependency graph. 

1035 

1036 :returns: An iterator over all storages in this dependency graph. 

1037 """ 

1038 return iter(self._storages.values()) 

1039 

1040 def __getitem__(self, name: str) -> PackageStorage: 

1041 """ 

1042 Access a known package storage in the package dependency graph by storage name. 

1043 

1044 :param name: Name of the package storage. 

1045 :returns: The package storage. 

1046 :raises KeyError: If package storage is not known within the package dependency graph. 

1047 """ 

1048 return self._storages[name] 

1049 

1050 def __str__(self) -> str: 

1051 """ 

1052 Return a string representation of this graph. 

1053 

1054 :returns: The graph's name and number of known packages. 

1055 """ 

1056 count = sum(len(storage) for storage in self._storages.values()) 

1057 if count == 0: 1057 ↛ 1060line 1057 didn't jump to line 1060 because the condition on line 1057 was always true

1058 return f"{self._name} (empty)" 

1059 else: 

1060 return f"{self._name} ({count})"