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

602 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-25 12:33 +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 a dependency graph converts to (:meth:`~pyTooling.Dependency.PackageDependencyGraph.ToGraph`). 

46""" 

47from __future__ import annotations 

48 

49from datetime import datetime 

50from pathlib import Path 

51from typing import TYPE_CHECKING, Optional as Nullable, Iterable, Self, Iterator 

52 

53from pyTooling.Decorators import export, readonly 

54from pyTooling.MetaClasses import ExtendedType 

55from pyTooling.Exceptions import AlreadyInHierarchyError, ToolingException 

56from pyTooling.Common import getFullyQualifiedName, firstKey, firstValue 

57from pyTooling.GenericPath.URL import URL 

58from pyTooling.Graph import Graph, Vertex 

59from pyTooling.Licensing import LicenseExpression, BaseLicense, UnknownLicense 

60from pyTooling.Versioning import SemanticVersion 

61from pyTooling.Warning import CriticalWarning, Warning 

62 

63if TYPE_CHECKING: # pragma: no cover 

64 from packaging.requirements import Requirement 

65 from pyTooling.Dependency.Resolver import ResolvedRequirement 

66 

67 

68@export 

69class DependencyError(ToolingException): 

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

71 

72 

73@export 

74class NoSessionAvailableError(DependencyError): 

75 """ 

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

77 

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

79 """ 

80 

81 _projectName: Nullable[str] #: Name of the project that was to be requested. 

82 _version: Nullable[SemanticVersion] #: Version of the release that was to be requested. 

83 

84 def __init__( 

85 self, 

86 message: str, 

87 /, *, 

88 projectName: Nullable[str] = None, 

89 version: Nullable[SemanticVersion] = None 

90 ) -> None: 

91 """ 

92 Initializes the exception with the project and, for a release, the version that was to be requested. 

93 

94 :param message: The exception's message. 

95 :param projectName: Optional, the name of the project that was to be requested. 

96 :param version: Optional, the version of the release that was to be requested; ``None`` for the project itself. 

97 """ 

98 super().__init__(message) 

99 self._projectName = projectName 

100 self._version = version 

101 

102 @readonly 

103 def ProjectName(self) -> Nullable[str]: 

104 """ 

105 Read-only property to access the name of the project that was to be requested (:attr:`_projectName`). 

106 

107 :returns: The project's name, or ``None`` if it wasn't recorded. 

108 """ 

109 return self._projectName 

110 

111 @readonly 

112 def Version(self) -> Nullable[SemanticVersion]: 

113 """ 

114 Read-only property to access the version of the release that was to be requested (:attr:`_version`). 

115 

116 :returns: The release's version, or ``None`` if it wasn't recorded. 

117 """ 

118 return self._version 

119 

120 

121@export 

122class RequirementsFileNotFoundError(DependencyError, FileNotFoundError): 

123 """ 

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

125 

126 It is a :exc:`FileNotFoundError` as well (and so an :exc:`OSError`), because the file doesn't exist; catching 

127 :exc:`~pyTooling.Dependency.DependencyError` still catches it. 

128 """ 

129 

130 _filePath: Nullable[Path] #: Path of the requirements file that doesn't exist. 

131 

132 def __init__(self, message: str, /, *, filePath: Nullable[Path] = None) -> None: 

133 """ 

134 Initializes the exception with the path of the requirements file that doesn't exist. 

135 

136 :param message: The exception's message. 

137 :param filePath: Optional, the path of the requirements file that doesn't exist. 

138 """ 

139 super().__init__(message) 

140 self._filePath = filePath 

141 

142 @readonly 

143 def FilePath(self) -> Nullable[Path]: 

144 """ 

145 Read-only property to access the path of the requirements file that doesn't exist (:attr:`_filePath`). 

146 

147 :returns: The path, or ``None`` if it wasn't recorded. 

148 """ 

149 return self._filePath 

150 

151 

152@export 

153class CircularRequirementsFileError(DependencyError): 

154 """ 

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

156 

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

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

159 two files reference, without one being read inside the other, is no cycle. 

160 """ 

161 

162 _filePath: Nullable[Path] #: Path of the requirements file that is already being read. 

163 _referencedIn: Nullable[Path] #: Path of the file whose ``-r`` line references it again. 

164 _lineNumber: Nullable[int] #: Number of that ``-r`` line. 

165 

166 def __init__( 

167 self, 

168 message: str, 

169 /, *, 

170 filePath: Nullable[Path] = None, 

171 referencedIn: Nullable[Path] = None, 

172 lineNumber: Nullable[int] = None 

173 ) -> None: 

174 """ 

175 Initializes the exception with the file read again and the ``-r`` line referencing it. 

176 

177 :param message: The exception's message. 

178 :param filePath: Optional, the path of the requirements file that is already being read. 

179 :param referencedIn: Optional, the path of the file whose ``-r`` line references it again. 

180 :param lineNumber: Optional, the number of that ``-r`` line. 

181 """ 

182 super().__init__(message) 

183 self._filePath = filePath 

184 self._referencedIn = referencedIn 

185 self._lineNumber = lineNumber 

186 

187 @readonly 

188 def FilePath(self) -> Nullable[Path]: 

189 """ 

190 Read-only property to access the path of the requirements file that is already being read (:attr:`_filePath`). 

191 

192 :returns: The path, or ``None`` if it wasn't recorded. 

193 """ 

194 return self._filePath 

195 

196 @readonly 

197 def ReferencedIn(self) -> Nullable[Path]: 

198 """ 

199 Read-only property to access the path of the file whose ``-r`` line references it again (:attr:`_referencedIn`). 

200 

201 :returns: The path, or ``None`` if it wasn't recorded. 

202 """ 

203 return self._referencedIn 

204 

205 @readonly 

206 def LineNumber(self) -> Nullable[int]: 

207 """ 

208 Read-only property to access the number of that ``-r`` line (:attr:`_lineNumber`). 

209 

210 :returns: The line number, or ``None`` if it wasn't recorded. 

211 """ 

212 return self._lineNumber 

213 

214 

215@export 

216class AlreadyInRequirementsTreeError(DependencyError, AlreadyInHierarchyError): 

217 """A requirements file is given as an entry to another one, but a third file references it already.""" 

218 

219 

220@export 

221class ProjectNotFoundError(DependencyError, LookupError): 

222 """ 

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

224 

225 It is a :exc:`LookupError` as well, because the project is looked up by name; catching 

226 :exc:`~pyTooling.Dependency.DependencyError` still catches it. 

227 """ 

228 

229 _projectName: Nullable[str] #: Name of the project the package index doesn't know. 

230 

231 def __init__(self, message: str, /, *, projectName: Nullable[str] = None) -> None: 

232 """ 

233 Initializes the exception with the name of the project that wasn't found. 

234 

235 :param message: The exception's message. 

236 :param projectName: Optional, the name of the project the package index doesn't know. 

237 """ 

238 super().__init__(message) 

239 self._projectName = projectName 

240 

241 @readonly 

242 def ProjectName(self) -> Nullable[str]: 

243 """ 

244 Read-only property to access the name of the project that wasn't found (:attr:`_projectName`). 

245 

246 :returns: The project's name, or ``None`` if it wasn't recorded. 

247 """ 

248 return self._projectName 

249 

250 

251@export 

252class ReleaseNotFoundError(DependencyError, LookupError): 

253 """ 

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

255 

256 It is a :exc:`LookupError` as well, because the release is looked up by version; catching 

257 :exc:`~pyTooling.Dependency.DependencyError` still catches it. 

258 """ 

259 

260 _projectName: Nullable[str] #: Name of the project the release was requested of. 

261 _version: Nullable[SemanticVersion] #: Version of the release the package index doesn't know. 

262 

263 def __init__( 

264 self, 

265 message: str, 

266 /, 

267 *, 

268 projectName: Nullable[str] = None, 

269 version: Nullable[SemanticVersion] = None 

270 ) -> None: 

271 """ 

272 Initializes the exception with the project and the version of the release that wasn't found. 

273 

274 :param message: The exception's message. 

275 :param projectName: Optional, the name of the project the release was requested of. 

276 :param version: Optional, the version of the release the package index doesn't know. 

277 """ 

278 super().__init__(message) 

279 self._projectName = projectName 

280 self._version = version 

281 

282 @readonly 

283 def ProjectName(self) -> Nullable[str]: 

284 """ 

285 Read-only property to access the name of the project the release was requested of (:attr:`_projectName`). 

286 

287 :returns: The project's name, or ``None`` if it wasn't recorded. 

288 """ 

289 return self._projectName 

290 

291 @readonly 

292 def Version(self) -> Nullable[SemanticVersion]: 

293 """ 

294 Read-only property to access the version of the release that wasn't found (:attr:`_version`). 

295 

296 :returns: The release's version, or ``None`` if it wasn't recorded. 

297 """ 

298 return self._version 

299 

300 

301@export 

302class PackageIndexError(DependencyError): 

303 """The package index responded with an HTTP error status other than ``404 Not Found``.""" 

304 

305 _projectName: str #: Name of the project requested from the package index. 

306 _version: Nullable[SemanticVersion] #: Version of the release requested, or ``None`` for the project itself. 

307 

308 def __init__( 

309 self, 

310 message: str, 

311 /, 

312 *, 

313 projectName: str, 

314 version: Nullable[SemanticVersion] = None 

315 ) -> None: 

316 """ 

317 Initializes the exception with the project and, for a release, the version requested from the package index. 

318 

319 :param message: The exception's message. 

320 :param projectName: The name of the project requested from the package index. 

321 :param version: Optional, the version of the release requested; ``None`` for the project itself. 

322 """ 

323 super().__init__(message) 

324 self._projectName = projectName 

325 self._version = version 

326 

327 @readonly 

328 def ProjectName(self) -> str: 

329 """ 

330 Read-only property to access the name of the project requested from the package index (:attr:`_projectName`). 

331 

332 :returns: The project's name. 

333 """ 

334 return self._projectName 

335 

336 @readonly 

337 def Version(self) -> Nullable[SemanticVersion]: 

338 """ 

339 Read-only property to access the version of the release requested (:attr:`_version`). 

340 

341 :returns: The release's version, or ``None`` for a request of the project itself or if it wasn't recorded. 

342 """ 

343 return self._version 

344 

345 

346@export 

347class ExtraNotFoundError(DependencyError, LookupError): 

348 """ 

349 A release doesn't declare the extra whose requirements were asked for. 

350 

351 It is a :exc:`LookupError` as well, because the extra is looked up by name; catching 

352 :exc:`~pyTooling.Dependency.DependencyError` still catches it. 

353 """ 

354 

355 _projectName: str #: Name of the project the release belongs to. 

356 _version: SemanticVersion #: Version of the release. 

357 _extra: str #: Name of the extra the release doesn't declare. 

358 

359 def __init__( 

360 self, 

361 message: str, 

362 /, *, 

363 projectName: str, 

364 version: SemanticVersion, 

365 extra: str 

366 ) -> None: 

367 """ 

368 Initializes the exception with the release and the extra it doesn't declare. 

369 

370 :param message: The exception's message. 

371 :param projectName: The name of the project the release belongs to. 

372 :param version: The version of the release. 

373 :param extra: The name of the extra the release doesn't declare. 

374 """ 

375 super().__init__(message) 

376 self._projectName = projectName 

377 self._version = version 

378 self._extra = extra 

379 

380 @readonly 

381 def ProjectName(self) -> str: 

382 """ 

383 Read-only property to access the name of the project the release belongs to (:attr:`_projectName`). 

384 

385 :returns: The project's name. 

386 """ 

387 return self._projectName 

388 

389 @readonly 

390 def Version(self) -> SemanticVersion: 

391 """ 

392 Read-only property to access the version of the release (:attr:`_version`). 

393 

394 :returns: The release's version. 

395 """ 

396 return self._version 

397 

398 @readonly 

399 def Extra(self) -> str: 

400 """ 

401 Read-only property to access the name of the extra the release doesn't declare (:attr:`_extra`). 

402 

403 :returns: The extra's name. 

404 """ 

405 return self._extra 

406 

407 

408@export 

409class BrokenRequirementWarning(CriticalWarning): 

410 """ 

411 Base-class of the warnings about a stated requirement that can't be used as stated. 

412 

413 A line of a requirements file can't be parsed, or a requirement names an extra the selected release doesn't 

414 declare. The requirement is left out, so a dependency the caller relies on is missing. Catch this class to handle 

415 both; each derived class carries what it is about. 

416 

417 .. important:: 

418 

419 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the 

420 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised. 

421 """ 

422 

423 

424@export 

425class UnparsableRequirementWarning(BrokenRequirementWarning): 

426 """ 

427 A line of a requirements file can't be parsed as a requirement, so it's left out of the file. 

428 

429 .. important:: 

430 

431 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the 

432 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised. 

433 """ 

434 

435 _line: Nullable[str] #: The line's text. 

436 _filePath: Nullable[Path] #: Path of the requirements file. 

437 _lineNumber: Nullable[int] #: Number of the line in the requirements file. 

438 

439 def __init__( 

440 self, 

441 message: str, 

442 /, *, 

443 line: Nullable[str] = None, 

444 filePath: Nullable[Path] = None, 

445 lineNumber: Nullable[int] = None 

446 ) -> None: 

447 """ 

448 Initializes the warning with the line that can't be parsed and where it is. 

449 

450 :param message: The warning's message. 

451 :param line: Optional, the line's text. 

452 :param filePath: Optional, the path of the requirements file. 

453 :param lineNumber: Optional, the number of the line in the requirements file. 

454 """ 

455 super().__init__(message) 

456 self._line = line 

457 self._filePath = filePath 

458 self._lineNumber = lineNumber 

459 

460 @readonly 

461 def Line(self) -> Nullable[str]: 

462 """ 

463 Read-only property to access the line's text (:attr:`_line`). 

464 

465 :returns: The line, or ``None`` if it wasn't recorded. 

466 """ 

467 return self._line 

468 

469 @readonly 

470 def FilePath(self) -> Nullable[Path]: 

471 """ 

472 Read-only property to access the path of the requirements file (:attr:`_filePath`). 

473 

474 :returns: The file's path, or ``None`` if it wasn't recorded. 

475 """ 

476 return self._filePath 

477 

478 @readonly 

479 def LineNumber(self) -> Nullable[int]: 

480 """ 

481 Read-only property to access the number of the line in the requirements file (:attr:`_lineNumber`). 

482 

483 :returns: The line number, or ``None`` if it wasn't recorded. 

484 """ 

485 return self._lineNumber 

486 

487 

488@export 

489class UndeclaredExtraWarning(BrokenRequirementWarning): 

490 """ 

491 A requirement names an extra (``pyTooling[terminal]``) the selected release doesn't declare. 

492 

493 The extra isn't followed; the release's other requirements are. 

494 

495 .. important:: 

496 

497 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the 

498 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised. 

499 """ 

500 

501 _requirement: Nullable[ResolvedRequirement] #: The requirement naming the extra. 

502 _extra: Nullable[str] #: The extra's name, as the requirement states it. 

503 _release: Nullable[PackageVersion] #: The release selected for the requirement. 

504 

505 def __init__( 

506 self, 

507 message: str, 

508 /, *, 

509 requirement: Nullable[ResolvedRequirement] = None, 

510 extra: Nullable[str] = None, 

511 release: Nullable[PackageVersion] = None 

512 ) -> None: 

513 """ 

514 Initializes the warning with the requirement, the extra it names and the release not declaring it. 

515 

516 :param message: The warning's message. 

517 :param requirement: Optional, the requirement naming the extra. 

518 :param extra: Optional, the extra's name, as the requirement states it. 

519 :param release: Optional, the release selected for the requirement. 

520 """ 

521 super().__init__(message) 

522 self._requirement = requirement 

523 self._extra = extra 

524 self._release = release 

525 

526 @readonly 

527 def Requirement(self) -> Nullable[ResolvedRequirement]: 

528 """ 

529 Read-only property to access the requirement naming the extra (:attr:`_requirement`). 

530 

531 :returns: The resolved requirement, or ``None`` if it wasn't recorded. 

532 """ 

533 return self._requirement 

534 

535 @readonly 

536 def Extra(self) -> Nullable[str]: 

537 """ 

538 Read-only property to access the extra's name, as the requirement states it (:attr:`_extra`). 

539 

540 :returns: The extra's name, or ``None`` if it wasn't recorded. 

541 """ 

542 return self._extra 

543 

544 @readonly 

545 def Release(self) -> Nullable[PackageVersion]: 

546 """ 

547 Read-only property to access the release selected for the requirement (:attr:`_release`). 

548 

549 :returns: The release, or ``None`` if it wasn't recorded. 

550 """ 

551 return self._release 

552 

553 

554@export 

555class UndeclaredExtraMarkerWarning(Warning): 

556 """ 

557 Requirements of a release have a marker naming an extra the release doesn't declare. 

558 

559 Such a requirement can't be assigned to an extra, so it's not reachable through 

560 :attr:`Release.Requirements <pyTooling.Dependency.Python.Release.Requirements>`. It's a defect of the release's 

561 metadata, not a dependency missing from the result: pip doesn't install such a requirement either, as it installs 

562 the requirements of the extras a release declares only. 

563 

564 .. attention:: 

565 

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

567 dropped silently. 

568 """ 

569 

570 _release: Nullable[PackageVersion] #: The release stating the requirements. 

571 _requirements: tuple[Requirement, ...] #: The requirements whose marker names an undeclared extra. 

572 

573 def __init__( 

574 self, 

575 message: str, 

576 /, *, 

577 release: Nullable[PackageVersion] = None, 

578 requirements: Nullable[Iterable[Requirement]] = None 

579 ) -> None: 

580 """ 

581 Initializes the warning with the release and the requirements it can't assign to an extra. 

582 

583 :param message: The warning's message. 

584 :param release: Optional, the release stating the requirements. 

585 :param requirements: Optional, the requirements whose marker names an undeclared extra. 

586 """ 

587 super().__init__(message) 

588 self._release = release 

589 self._requirements = () if requirements is None else tuple(requirements) 

590 

591 @readonly 

592 def Release(self) -> Nullable[PackageVersion]: 

593 """ 

594 Read-only property to access the release stating the requirements (:attr:`_release`). 

595 

596 :returns: The release, or ``None`` if it wasn't recorded. 

597 """ 

598 return self._release 

599 

600 @readonly 

601 def Requirements(self) -> tuple[Requirement, ...]: 

602 """ 

603 Read-only property to access the requirements whose marker names an undeclared extra (:attr:`_requirements`). 

604 

605 :returns: Tuple of requirements; empty if they weren't recorded. 

606 """ 

607 return self._requirements 

608 

609 

610@export 

611class UnknownLicenseWarning(Warning): 

612 """ 

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

614 

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

616 what would have to be stated by hand. 

617 

618 .. attention:: 

619 

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

621 dropped silently. 

622 """ 

623 

624 _projectName: Nullable[str] #: Name of the release's project. 

625 _version: Nullable[SemanticVersion] #: Version of the release. 

626 

627 def __init__( 

628 self, 

629 message: str, 

630 /, *, 

631 projectName: Nullable[str] = None, 

632 version: Nullable[SemanticVersion] = None 

633 ) -> None: 

634 """ 

635 Initializes the warning with the release whose license couldn't be resolved. 

636 

637 :param message: The warning's message. 

638 :param projectName: Optional, the name of the release's project. 

639 :param version: Optional, the version of the release. 

640 """ 

641 super().__init__(message) 

642 self._projectName = projectName 

643 self._version = version 

644 

645 @readonly 

646 def ProjectName(self) -> Nullable[str]: 

647 """ 

648 Read-only property to access the name of the release's project (:attr:`_projectName`). 

649 

650 :returns: The project's name, or ``None`` if it wasn't recorded. 

651 """ 

652 return self._projectName 

653 

654 @readonly 

655 def Version(self) -> Nullable[SemanticVersion]: 

656 """ 

657 Read-only property to access the version of the release (:attr:`_version`). 

658 

659 :returns: The release's version, or ``None`` if it wasn't recorded. 

660 """ 

661 return self._version 

662 

663 

664@export 

665class ReleaseDetailsWarning(CriticalWarning): 

666 """ 

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

668 

669 .. important:: 

670 

671 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the 

672 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised. 

673 """ 

674 

675 _projectName: Nullable[str] #: Name of the release's project. 

676 _version: Nullable[SemanticVersion] #: Version of the release. 

677 

678 def __init__( 

679 self, 

680 message: str, 

681 /, *, 

682 projectName: Nullable[str] = None, 

683 version: Nullable[SemanticVersion] = None 

684 ) -> None: 

685 """ 

686 Initializes the warning with the release that was dropped. 

687 

688 :param message: The warning's message. 

689 :param projectName: Optional, the name of the release's project. 

690 :param version: Optional, the version of the release. 

691 """ 

692 super().__init__(message) 

693 self._projectName = projectName 

694 self._version = version 

695 

696 @readonly 

697 def ProjectName(self) -> Nullable[str]: 

698 """ 

699 Read-only property to access the name of the release's project (:attr:`_projectName`). 

700 

701 :returns: The project's name, or ``None`` if it wasn't recorded. 

702 """ 

703 return self._projectName 

704 

705 @readonly 

706 def Version(self) -> Nullable[SemanticVersion]: 

707 """ 

708 Read-only property to access the version of the release (:attr:`_version`). 

709 

710 :returns: The release's version, or ``None`` if it wasn't recorded. 

711 """ 

712 return self._version 

713 

714 

715@export 

716class UnresolvedRequirementWarning(Warning): 

717 """ 

718 A requirement couldn't be resolved: the package index doesn't know its project, or can't describe the release 

719 selected for it. 

720 

721 The requirement stays in the resolution, with the reason as its status. 

722 

723 .. attention:: 

724 

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

726 dropped silently. 

727 """ 

728 

729 _projectName: str #: Name of the required project. 

730 _version: Nullable[SemanticVersion] #: Version of the selected release, or ``None`` for an unknown project. 

731 

732 def __init__( 

733 self, 

734 message: str, 

735 /, *, 

736 projectName: str, 

737 version: Nullable[SemanticVersion] = None 

738 ) -> None: 

739 """ 

740 Initializes the warning with the required project and, if one was selected, the release. 

741 

742 :param message: The warning's message. 

743 :param projectName: The name of the required project. 

744 :param version: Optional, the version of the selected release; ``None`` for an unknown project. 

745 """ 

746 super().__init__(message) 

747 self._projectName = projectName 

748 self._version = version 

749 

750 @readonly 

751 def ProjectName(self) -> str: 

752 """ 

753 Read-only property to access the name of the required project (:attr:`_projectName`). 

754 

755 :returns: The project's name. 

756 """ 

757 return self._projectName 

758 

759 @readonly 

760 def Version(self) -> Nullable[SemanticVersion]: 

761 """ 

762 Read-only property to access the version of the release selected for the requirement (:attr:`_version`). 

763 

764 :returns: The release's version, or ``None`` for an unknown project or if it wasn't recorded. 

765 """ 

766 return self._version 

767 

768 

769@export 

770class PackageIndexFailureWarning(CriticalWarning): 

771 """ 

772 The package index failed to answer for a project or a release: it responded with a server error (``5xx``), or the 

773 request failed on the network. 

774 

775 The requirement stays in the resolution, with the status 

776 :attr:`~pyTooling.Dependency.Resolver.ResolutionStatus.IndexFailure`. The exception raised by the request is the 

777 warning's cause. 

778 

779 .. important:: 

780 

781 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the 

782 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised. 

783 """ 

784 

785 _projectName: str #: Name of the project requested. 

786 _version: Nullable[SemanticVersion] #: Version of the release requested, or ``None`` for the project itself. 

787 _statusCode: Nullable[int] #: HTTP status code of the response, or ``None`` for a network failure. 

788 

789 def __init__( 

790 self, 

791 message: str, 

792 /, *, 

793 projectName: str, 

794 version: Nullable[SemanticVersion] = None, 

795 statusCode: Nullable[int] 

796 ) -> None: 

797 """ 

798 Initializes the warning with the project and, for a release, the version requested, and the response's status. 

799 

800 :param message: The warning's message. 

801 :param projectName: The name of the project requested. 

802 :param version: Optional, the version of the release requested; ``None`` for the project itself. 

803 :param statusCode: The HTTP status code of the response; ``None`` for a network failure. 

804 """ 

805 super().__init__(message) 

806 self._projectName = projectName 

807 self._version = version 

808 self._statusCode = statusCode 

809 

810 @readonly 

811 def ProjectName(self) -> str: 

812 """ 

813 Read-only property to access the name of the project requested (:attr:`_projectName`). 

814 

815 :returns: The project's name. 

816 """ 

817 return self._projectName 

818 

819 @readonly 

820 def Version(self) -> Nullable[SemanticVersion]: 

821 """ 

822 Read-only property to access the version of the release requested (:attr:`_version`). 

823 

824 :returns: The release's version, or ``None`` for a request of the project itself or if it wasn't recorded. 

825 """ 

826 return self._version 

827 

828 @readonly 

829 def StatusCode(self) -> Nullable[int]: 

830 """ 

831 Read-only property to access the HTTP status code of the response (:attr:`_statusCode`). 

832 

833 :returns: The status code, or ``None`` for a network failure. 

834 """ 

835 return self._statusCode 

836 

837 

838@export 

839class ResolutionTimeoutWarning(CriticalWarning): 

840 """ 

841 The current :class:`~pyTooling.Timeout.Timeout` expired while a resolver resolved requirements. 

842 

843 The requirements it didn't get to stay in the resolution, with the status 

844 :attr:`~pyTooling.Dependency.Resolver.ResolutionStatus.TimeoutExpired`; the resolution returned is partial. 

845 

846 .. important:: 

847 

848 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the 

849 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised. 

850 """ 

851 

852 _seconds: Nullable[float] #: The configured timeout in seconds. 

853 _projectNames: Nullable[tuple[str, ...]] #: Names of the projects whose requirements weren't resolved. 

854 

855 def __init__( 

856 self, 

857 message: str, 

858 /, *, 

859 seconds: Nullable[float] = None, 

860 projectNames: Nullable[Iterable[str]] = None 

861 ) -> None: 

862 """ 

863 Initializes the warning with the configured timeout and the projects whose requirements weren't resolved. 

864 

865 :param message: The warning's message. 

866 :param seconds: Optional, the configured timeout in seconds. 

867 :param projectNames: Optional, the names of the projects whose requirements weren't resolved. 

868 """ 

869 super().__init__(message) 

870 self._seconds = seconds 

871 self._projectNames = None if projectNames is None else tuple(projectNames) 

872 

873 @readonly 

874 def Seconds(self) -> Nullable[float]: 

875 """ 

876 Read-only property to access the configured timeout in seconds (:attr:`_seconds`). 

877 

878 :returns: The timeout in seconds, or ``None`` if it wasn't recorded. 

879 """ 

880 return self._seconds 

881 

882 @readonly 

883 def ProjectNames(self) -> Nullable[tuple[str, ...]]: 

884 """ 

885 Read-only property to access the names of the projects whose requirements weren't resolved (:attr:`_projectNames`). 

886 

887 :returns: The project names, or ``None`` if they weren't recorded. 

888 """ 

889 return self._projectNames 

890 

891 

892@export 

893class UnsupportedVersionWarning(CriticalWarning): 

894 """ 

895 A release's version number has a format the version parser doesn't support, therefore the release was skipped. 

896 

897 A package index lists every release a project ever published, also those numbered before :pep:`440`, e.g. pytz' 

898 ``2004d``. A skipped release is missing from the project, so the warning names it. 

899 

900 .. important:: 

901 

902 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the 

903 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised. 

904 """ 

905 

906 _projectName: str #: Name of the project the skipped release belongs to. 

907 _version: str #: Version number of the skipped release, as published by the package index. 

908 

909 def __init__(self, message: str, /, *, projectName: str, version: str) -> None: 

910 """ 

911 Initializes the warning with the project and the version number that can't be parsed. 

912 

913 :param message: The warning's message. 

914 :param projectName: Name of the project the skipped release belongs to. 

915 :param version: Version number of the skipped release, as published by the package index. 

916 """ 

917 super().__init__(message) 

918 self._projectName = projectName 

919 self._version = version 

920 

921 @readonly 

922 def ProjectName(self) -> str: 

923 """ 

924 Read-only property to access the name of the project the skipped release belongs to (:attr:`_projectName`). 

925 

926 :returns: The project's name. 

927 """ 

928 return self._projectName 

929 

930 @readonly 

931 def Version(self) -> str: 

932 """ 

933 Read-only property to access the version number of the skipped release (:attr:`_version`). 

934 

935 :returns: The version number as published by the package index. 

936 """ 

937 return self._version 

938 

939 

940@export 

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

942 """ 

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

944 

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

946 :class:`PackageVersion`s. 

947 """ 

948 

949 _package: Package #: Reference to the corresponding package 

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

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

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

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

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

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

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

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

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

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

960 

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

962 """ 

963 Initializes a package version. 

964 

965 :param version: Semantic version of this package. 

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

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

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

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

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

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

972 """ 

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

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

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

976 raise ex 

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

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

979 

980 self._version = version 

981 package._versions[version] = self 

982 

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

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

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

986 raise ex 

987 

988 self._package = package 

989 

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

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

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

993 raise ex 

994 

995 self._releasedAt = releasedAt 

996 self._licenseExpression = UnknownLicense() 

997 self._licenseURL = None 

998 self._repositoryURL = None 

999 self._documentationURL = None 

1000 self._issueTrackerURL = None 

1001 self._projectURL = None 

1002 self._changelogURL = None 

1003 self._dependsOn = {} 

1004 

1005 @readonly 

1006 def Package(self) -> Package: 

1007 """ 

1008 Read-only property to access the associated package. 

1009 

1010 :returns: Associated package. 

1011 """ 

1012 return self._package 

1013 

1014 @readonly 

1015 def Version(self) -> SemanticVersion: 

1016 """ 

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

1018 

1019 :returns: Semantic version of a package. 

1020 """ 

1021 return self._version 

1022 

1023 @readonly 

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

1025 """ 

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

1027 

1028 :returns: Optional release date and time. 

1029 """ 

1030 return self._releasedAt 

1031 

1032 @readonly 

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

1034 """ 

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

1036 

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

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

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

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

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

1042 

1043 .. code-block:: python 

1044 

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

1046 

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

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

1049 

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

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

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

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

1054 

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

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

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

1058 

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

1060 """ 

1061 return tuple( 

1062 node 

1063 for node in self._licenseExpression.IterateExpression() 

1064 if isinstance(node, BaseLicense) 

1065 ) 

1066 

1067 @readonly 

1068 def LicenseExpression(self) -> LicenseExpression: 

1069 """ 

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

1071 

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

1073 a choice. 

1074 

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

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

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

1078 

1079 :returns: The license expression. 

1080 """ 

1081 return self._licenseExpression 

1082 

1083 @readonly 

1084 def PublishedLicense(self) -> str: 

1085 """ 

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

1087 

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

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

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

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

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

1093 

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

1095 """ 

1096 return self._licenseExpression.OriginalText 

1097 

1098 @readonly 

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

1100 """ 

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

1102 

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

1104 

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

1106 """ 

1107 return self._licenseURL 

1108 

1109 @readonly 

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

1111 """ 

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

1113 

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

1115 page per package. 

1116 

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

1118 URL before the migration and another after it. 

1119 

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

1121 """ 

1122 return self._repositoryURL 

1123 

1124 @readonly 

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

1126 """ 

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

1128 

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

1130 URL before the migration and another after it. 

1131 

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

1133 """ 

1134 return self._documentationURL 

1135 

1136 @readonly 

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

1138 """ 

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

1140 

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

1142 URL before the migration and another after it. 

1143 

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

1145 """ 

1146 return self._issueTrackerURL 

1147 

1148 @readonly 

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

1150 """ 

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

1152 

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

1154 URL before the migration and another after it. 

1155 

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

1157 """ 

1158 return self._projectURL 

1159 

1160 @readonly 

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

1162 """ 

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

1164 

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

1166 URL before the migration and another after it. 

1167 

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

1169 """ 

1170 return self._changelogURL 

1171 

1172 @readonly 

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

1174 """ 

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

1176 

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

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

1179 

1180 :returns: Dictionary of dependencies. 

1181 """ 

1182 return self._dependsOn 

1183 

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

1185 """ 

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

1187 

1188 :param packageVersion: Dependency to be added. 

1189 """ 

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

1191 pack = self._dependsOn[package] 

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

1193 pass 

1194 else: 

1195 pack[version] = packageVersion 

1196 else: 

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

1198 

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

1200 """ 

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

1202 

1203 :param packageVersions: Dependencies to be added. 

1204 """ 

1205 # TODO: check for iterable 

1206 

1207 for packageVersion in packageVersions: 

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

1209 pack = self._dependsOn[package] 

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

1211 pass 

1212 else: 

1213 pack[version] = packageVersion 

1214 else: 

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

1216 

1217 def AddDependencyTo( 

1218 self, 

1219 package: str | Package, 

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

1221 ) -> None: 

1222 """ 

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

1224 

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

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

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

1228 """ 

1229 if isinstance(package, str): 

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

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

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

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

1234 raise ex 

1235 

1236 if isinstance(version, str): 

1237 version = SemanticVersion.Parse(version) 

1238 elif isinstance(version, Iterable): 

1239 for v in version: 

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

1241 v = SemanticVersion.Parse(v) 

1242 elif not isinstance(v, SemanticVersion): 

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

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

1245 raise ex# 

1246 

1247 packageVersion = package._versions[v] 

1248 self.AddDependencyToPackageVersion(packageVersion) 

1249 

1250 return 

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

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

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

1254 raise ex 

1255 

1256 packageVersion = package._versions[version] 

1257 self.AddDependencyToPackageVersion(packageVersion) 

1258 

1259 def SortDependencies(self) -> Self: 

1260 """ 

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

1262 dictionaries. 

1263 

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

1265 """ 

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

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

1268 return self 

1269 

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

1271 """ 

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

1273 

1274 .. todo:: 

1275 

1276 Describe algorithm. 

1277 

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

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

1280 """ 

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

1282 

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

1284 """ 

1285 Nested function for recursion. 

1286 

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

1288 package is missing. 

1289 

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

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

1292 """ 

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

1294 requiredPackages: set[Package] = set() 

1295 for packageVersion in currentSolution.values(): 

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

1297 

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

1299 missingPackages = requiredPackages - currentSolution.keys() 

1300 

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

1302 if len(missingPackages) == 0: 

1303 return True 

1304 

1305 # 3. Pick the next package to resolve 

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

1307 targetPackage = next(iter(missingPackages)) 

1308 

1309 # 4. Determine valid candidates 

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

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

1312 

1313 for parentPackageVersion in currentSolution.values(): 

1314 if targetPackage in parentPackageVersion.DependsOn: 

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

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

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

1318 

1319 if allowedVersions is None: 

1320 allowedVersions = parentConstraints 

1321 else: 

1322 # Intersect with existing constraints (must satisfy everyone) 

1323 allowedVersions &= parentConstraints 

1324 

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

1326 if not allowedVersions: 

1327 return False 

1328 

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

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

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

1332 candidate = targetPackage.Versions[version_key] 

1333 

1334 # 6. Check compatibility (reverse dependencies) 

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

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

1337 isCompatible = True 

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

1339 if existingPackage in candidate.DependsOn: 

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

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

1342 isCompatible = False 

1343 break 

1344 

1345 if isCompatible: 

1346 # Tentatively add to solution 

1347 currentSolution[targetPackage] = candidate 

1348 

1349 # Recurse 

1350 if _recursion(currentSolution): 

1351 return True 

1352 

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

1354 del currentSolution[targetPackage] 

1355 

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

1357 return False 

1358 

1359 # Run the solver 

1360 if _recursion(solution): 

1361 return list(solution.values()) 

1362 else: 

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

1364 

1365 def __len__(self) -> int: 

1366 """ 

1367 Returns the number of dependencies. 

1368 

1369 :returns: Number of dependencies. 

1370 """ 

1371 return len(self._dependsOn) 

1372 

1373 def __str__(self) -> str: 

1374 """ 

1375 Return a string representation of this package version. 

1376 

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

1378 """ 

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

1380 

1381 

1382@export 

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

1384 """ 

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

1386 """ 

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

1388 _name: str #: Name of the package. 

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

1390 

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

1392 """ 

1393 Initializes a package. 

1394 

1395 :param name: Name of the package. 

1396 :param storage: The package's storage. 

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

1398 """ 

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

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

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

1402 raise ex 

1403 

1404 self._name = name 

1405 

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

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

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

1409 raise ex 

1410 

1411 self._storage = storage 

1412 storage._packages[name] = self 

1413 

1414 self._versions = {} 

1415 

1416 @readonly 

1417 def Storage(self) -> PackageStorage: 

1418 """ 

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

1420 

1421 :returns: Package storage. 

1422 """ 

1423 return self._storage 

1424 

1425 @readonly 

1426 def Name(self) -> str: 

1427 """ 

1428 Read-only property to access the package name. 

1429 

1430 :returns: Name of the package. 

1431 """ 

1432 return self._name 

1433 

1434 @readonly 

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

1436 """ 

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

1438 

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

1440 

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

1442 """ 

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

1444 return None 

1445 

1446 return firstValue(self._versions) 

1447 

1448 @readonly 

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

1450 """ 

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

1452 

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

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

1455 

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

1457 the package has no version. 

1458 """ 

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

1460 return None 

1461 

1462 return latest.RepositoryURL 

1463 

1464 @readonly 

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

1466 """ 

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

1468 

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

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

1471 

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

1473 """ 

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

1475 return None 

1476 

1477 return latest.DocumentationURL 

1478 

1479 @readonly 

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

1481 """ 

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

1483 

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

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

1486 

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

1488 """ 

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

1490 return None 

1491 

1492 return latest.IssueTrackerURL 

1493 

1494 @readonly 

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

1496 """ 

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

1498 

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

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

1501 

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

1503 the package has no version. 

1504 """ 

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

1506 return None 

1507 

1508 return latest.ProjectURL 

1509 

1510 @readonly 

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

1512 """ 

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

1514 

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

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

1517 

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

1519 """ 

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

1521 return None 

1522 

1523 return latest.ChangelogURL 

1524 

1525 @readonly 

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

1527 """ 

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

1529 

1530 :returns: Available version dictionary. 

1531 """ 

1532 return self._versions 

1533 

1534 @readonly 

1535 def VersionCount(self) -> int: 

1536 """ 

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

1538 

1539 :returns: Number of versions. 

1540 """ 

1541 return len(self._versions) 

1542 

1543 def SortVersions(self) -> None: 

1544 """ 

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

1546 """ 

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

1548 

1549 def __len__(self) -> int: 

1550 """ 

1551 Returns the number of available versions. 

1552 

1553 :returns: Number of versions. 

1554 """ 

1555 return len(self._versions) 

1556 

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

1558 """ 

1559 Iterate the versions of this package. 

1560 

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

1562 """ 

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

1564 

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

1566 """ 

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

1568 

1569 :param version: Version as string or instance. 

1570 :returns: The package version. 

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

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

1573 """ 

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

1575 version = SemanticVersion.Parse(version) 

1576 elif not isinstance(version, SemanticVersion): 

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

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

1579 raise ex 

1580 

1581 return self._versions[version] 

1582 

1583 def __str__(self) -> str: 

1584 """ 

1585 Return a string representation of this package. 

1586 

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

1588 """ 

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

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

1591 else: 

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

1593 

1594 

1595@export 

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

1597 """ 

1598 A storage for packages. 

1599 """ 

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

1601 _name: str #: Package dependency graph name 

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

1603 

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

1605 """ 

1606 Initializes the package storage. 

1607 

1608 :param name: Name of the package storage. 

1609 :param graph: PackageDependencyGraph instance (parent). 

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

1611 """ 

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

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

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

1615 raise ex 

1616 

1617 self._name = name 

1618 

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

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

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

1622 raise ex 

1623 

1624 self._graph = graph 

1625 graph._storages[name] = self 

1626 

1627 self._packages = {} 

1628 

1629 @readonly 

1630 def Graph(self) -> PackageDependencyGraph: 

1631 """ 

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

1633 

1634 :returns: Package dependency graph. 

1635 """ 

1636 return self._graph 

1637 

1638 @readonly 

1639 def Name(self) -> str: 

1640 """ 

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

1642 

1643 :returns: Name of the package dependency graph. 

1644 """ 

1645 return self._name 

1646 

1647 @readonly 

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

1649 """ 

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

1651 

1652 :returns: Known packages dictionary. 

1653 """ 

1654 return self._packages 

1655 

1656 @readonly 

1657 def PackageCount(self) -> int: 

1658 """ 

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

1660 

1661 :returns: Number of packages. 

1662 """ 

1663 return len(self._packages) 

1664 

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

1666 """ 

1667 Create a new package in the package dependency graph. 

1668 

1669 :param packageName: Name of the new package. 

1670 :returns: New package's instance. 

1671 """ 

1672 return Package(packageName, storage=self) 

1673 

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

1675 """ 

1676 Create multiple new packages in the package dependency graph. 

1677 

1678 :param packageNames: List of package names. 

1679 :returns: List of new package instances. 

1680 """ 

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

1682 

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

1684 """ 

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

1686 

1687 :param packageName: Name of the new package. 

1688 :param version: Version string. 

1689 :returns: New package version instance. 

1690 """ 

1691 package = Package(packageName, storage=self) 

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

1693 

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

1695 """ 

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

1697 

1698 :param packageName: Name of the new package. 

1699 :param versions: List of version string.s 

1700 :returns: List of new package version instances. 

1701 """ 

1702 package = Package(packageName, storage=self) 

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

1704 

1705 def SortPackageVersions(self) -> None: 

1706 """ 

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

1708 """ 

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

1710 package.SortVersions() 

1711 

1712 def __len__(self) -> int: 

1713 """ 

1714 Returns the number of known packages. 

1715 

1716 :returns: Number of packages. 

1717 """ 

1718 return len(self._packages) 

1719 

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

1721 """ 

1722 Iterate the packages in this storage. 

1723 

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

1725 """ 

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

1727 

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

1729 """ 

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

1731 

1732 :param name: Name of the package. 

1733 :returns: The package. 

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

1735 """ 

1736 return self._packages[name] 

1737 

1738 def __str__(self) -> str: 

1739 """ 

1740 Return a string representation of this graph. 

1741 

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

1743 """ 

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

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

1746 else: 

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

1748 

1749 

1750@export 

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

1752 """ 

1753 A package dependency graph collecting all known packages. 

1754 """ 

1755 _name: str #: Package dependency graph name 

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

1757 

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

1759 """ 

1760 Initializes the package dependency graph. 

1761 

1762 :param name: Name of the dependency graph. 

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

1764 """ 

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

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

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

1768 raise ex 

1769 

1770 self._name = name 

1771 

1772 self._storages = {} 

1773 

1774 @readonly 

1775 def Name(self) -> str: 

1776 """ 

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

1778 

1779 :returns: Name of the package dependency graph. 

1780 """ 

1781 return self._name 

1782 

1783 @readonly 

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

1785 """ 

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

1787 

1788 :returns: Known package storage dictionary. 

1789 """ 

1790 return self._storages 

1791 

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

1793 # """ 

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

1795 # 

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

1797 # :returns: New package's instance. 

1798 # """ 

1799 # return Package(packageName, storage=self) 

1800 # 

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

1802 # """ 

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

1804 # 

1805 # :param packageNames: List of package names. 

1806 # :returns: List of new package instances. 

1807 # """ 

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

1809 # 

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

1811 # """ 

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

1813 # 

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

1815 # :param version: Version string. 

1816 # :returns: New package version instance. 

1817 # """ 

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

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

1820 # 

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

1822 # """ 

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

1824 # 

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

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

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

1828 # """ 

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

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

1831 

1832 def SortPackageVersions(self) -> None: 

1833 """ 

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

1835 """ 

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

1837 storage.SortPackageVersions() 

1838 

1839 def ToGraph(self) -> Graph: 

1840 """ 

1841 Convert the package dependency graph into a graph of package versions and their dependencies. 

1842 

1843 Every package version of every storage becomes a :class:`~pyTooling.Graph.Vertex` of the graph, with the 

1844 :class:`PackageVersion` as its :attr:`~pyTooling.Graph.Vertex.ID`, so 

1845 :meth:`Graph.GetVertexByID <pyTooling.Graph.Graph.GetVertexByID>` finds a version's vertex. Its key-value pairs 

1846 keep the version's typed objects: 

1847 

1848 * ``license`` - the :class:`~pyTooling.Licensing.LicenseExpression`, an 

1849 :class:`~pyTooling.Licensing.UnknownLicense` if it's unknown; 

1850 * ``releasedAt`` - the release time as :class:`~datetime.datetime`; 

1851 * ``licenseURL``, ``repositoryURL``, ``documentationURL``, ``issueTrackerURL``, ``projectURL`` and 

1852 ``changelogURL`` - each a :class:`~pyTooling.GenericPath.URL.URL`. 

1853 

1854 All but ``license`` are set only if the version knows them; for a Python package, after its details were loaded. The 

1855 format is described in :ref:`DEPENDENCIES/Graph`. 

1856 

1857 Every dependency becomes an :class:`~pyTooling.Graph.Edge` from the package version needing to the package 

1858 version it needs, so an edge reads *needs*, and 

1859 :meth:`Graph.IterateTopologically <pyTooling.Graph.BaseGraph.IterateTopologically>` yields the package versions 

1860 dependencies first. 

1861 

1862 :returns: The graph, named like the package dependency graph. 

1863 """ 

1864 graph = Graph(name=self._name) 

1865 

1866 vertices: dict[PackageVersion, Vertex] = {} 

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

1868 for package in storage._packages.values(): 

1869 for packageVersion in package._versions.values(): 

1870 keyValuePairs = { 

1871 "license": packageVersion._licenseExpression 

1872 } 

1873 if packageVersion._releasedAt is not None: 

1874 keyValuePairs["releasedAt"] = packageVersion._releasedAt 

1875 

1876 for key, url in ( 

1877 ("licenseURL", packageVersion._licenseURL), 

1878 ("repositoryURL", packageVersion._repositoryURL), 

1879 ("documentationURL", packageVersion._documentationURL), 

1880 ("issueTrackerURL", packageVersion._issueTrackerURL), 

1881 ("projectURL", packageVersion._projectURL), 

1882 ("changelogURL", packageVersion._changelogURL) 

1883 ): 

1884 if url is not None: 

1885 keyValuePairs[key] = url 

1886 

1887 vertices[packageVersion] = Vertex(vertexID=packageVersion, keyValuePairs=keyValuePairs, graph=graph) 

1888 

1889 for packageVersion, vertex in vertices.items(): 

1890 for dependencies in packageVersion._dependsOn.values(): 

1891 for dependency in dependencies.values(): 

1892 vertex.EdgeToVertex(vertices[dependency]) 

1893 

1894 return graph 

1895 

1896 def __len__(self) -> int: 

1897 """ 

1898 Returns the number of known packages. 

1899 

1900 :returns: Number of packages. 

1901 """ 

1902 return len(self._storages) 

1903 

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

1905 """ 

1906 Iterate the storages in this dependency graph. 

1907 

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

1909 """ 

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

1911 

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

1913 """ 

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

1915 

1916 :param name: Name of the package storage. 

1917 :returns: The package storage. 

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

1919 """ 

1920 return self._storages[name] 

1921 

1922 def __str__(self) -> str: 

1923 """ 

1924 Return a string representation of this graph. 

1925 

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

1927 """ 

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

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

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

1931 else: 

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