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
« 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.
34.. hint::
36 See :ref:`high-level help <DEPENDENCIES>` for explanations and usage examples.
38.. seealso::
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
49from datetime import datetime
50from typing import Optional as Nullable, Iterable, Self, Iterator
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
62@export
63class DependencyError(ToolingException):
64 """Base-exception of all exceptions raised by :mod:`pyTooling.Dependency`."""
67@export
68class NoSessionAvailableError(DependencyError):
69 """
70 The operation needs a session to the package index, but no session was opened.
72 A session is created by the package index and handed to the objects it creates.
73 """
76@export
77class RequirementsFileNotFoundError(DependencyError):
78 """A requirements file doesn't exist, or a ``-r`` line references one that doesn't."""
81@export
82class CircularRequirementsFileError(DependencyError):
83 """
84 A ``-r`` line references a requirements file that is already being read further up the include chain.
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 """
91@export
92class ProjectNotFoundError(DependencyError):
93 """The package index doesn't know a project of that name."""
96@export
97class ReleaseNotFoundError(DependencyError):
98 """The project exists in the package index, but not in the requested version."""
101@export
102class BrokenRequirementWarning(Warning):
103 """
104 A requirement names an extra the project doesn't declare.
106 Such a requirement can't be assigned to an extra, so it's not reachable through :attr:`Requirements`.
107 """
110@export
111class UnknownLicenseWarning(Warning):
112 """
113 A package version's license couldn't be resolved from what its package index publishes.
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 """
120@export
121class ReleaseDetailsWarning(Warning):
122 """Downloading the details of a release failed, therefore the release was dropped from the project."""
125@export
126class PackageVersion(metaclass=ExtendedType, slots=True):
127 """
128 The package's version of a :class:`Package`.
130 A :class:`Package` has multiple available versions. A version can have multiple dependencies to other
131 :class:`PackageVersion`s.
132 """
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.
146 def __init__(self, version: SemanticVersion, package: Package, releasedAt: Nullable[datetime] = None) -> None:
147 """
148 Initializes a package version.
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}'.")
165 self._version = version
166 package._versions[version] = self
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
173 self._package = package
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
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 = {}
190 @readonly
191 def Package(self) -> Package:
192 """
193 Read-only property to access the associated package.
195 :returns: Associated package.
196 """
197 return self._package
199 @readonly
200 def Version(self) -> SemanticVersion:
201 """
202 Read-only property to access the semantic version of a package.
204 :returns: Semantic version of a package.
205 """
206 return self._version
208 @readonly
209 def ReleasedAt(self) -> Nullable[datetime]:
210 """
211 Read-only property to access the release date and time.
213 :returns: Optional release date and time.
214 """
215 return self._releasedAt
217 @readonly
218 def Licenses(self) -> tuple[BaseLicense, ...]:
219 """
220 Read-only property to return the licenses named by :attr:`LicenseExpression`.
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:
228 .. code-block:: python
230 [term.Identifier for term in version.Licenses] # ['MIT', 'LicenseRef-Proprietary']
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.
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.
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.
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 )
252 @readonly
253 def LicenseExpression(self) -> LicenseExpression:
254 """
255 Read-only property to access the license expression (:attr:`_licenseExpression`).
257 The expression keeps what :attr:`Licenses` flattens away - which licenses are required together and which are
258 a choice.
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``.
264 :returns: The license expression.
265 """
266 return self._licenseExpression
268 @readonly
269 def PublishedLicense(self) -> str:
270 """
271 Read-only property to access the license as it was published.
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``.
279 :returns: The license as published, or an empty string if nothing was published.
280 """
281 return self._licenseExpression.OriginalText
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`).
288 A package index has no field for it, so this is only known where it was stated by hand.
290 :returns: URL of the license's text, or ``None`` if unknown.
291 """
292 return self._licenseURL
294 @readonly
295 def RepositoryURL(self) -> Nullable[URL]:
296 """
297 Read-only property to access the URL of the source repository (:attr:`_repositoryURL`).
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.
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.
305 :returns: URL of the source repository, or ``None`` if this release didn't name one.
306 """
307 return self._repositoryURL
309 @readonly
310 def DocumentationURL(self) -> Nullable[URL]:
311 """
312 Read-only property to access the URL of the documentation (:attr:`_documentationURL`).
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.
317 :returns: URL of the documentation, or ``None`` if this release didn't name one.
318 """
319 return self._documentationURL
321 @readonly
322 def IssueTrackerURL(self) -> Nullable[URL]:
323 """
324 Read-only property to access the URL of the issue tracker (:attr:`_issueTrackerURL`).
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.
329 :returns: URL of the issue tracker, or ``None`` if this release didn't name one.
330 """
331 return self._issueTrackerURL
333 @readonly
334 def ProjectURL(self) -> Nullable[URL]:
335 """
336 Read-only property to access the URL of the project's homepage (:attr:`_projectURL`).
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.
341 :returns: URL of the project's homepage, or ``None`` if this release didn't name one.
342 """
343 return self._projectURL
345 @readonly
346 def ChangelogURL(self) -> Nullable[URL]:
347 """
348 Read-only property to access the URL of the changelog (:attr:`_changelogURL`).
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.
353 :returns: URL of the changelog, or ``None`` if this release didn't name one.
354 """
355 return self._changelogURL
357 @readonly
358 def DependsOn(self) -> dict[Package, dict[SemanticVersion, PackageVersion]]:
359 """
360 Read-only property to access the dictionary of dictionaries referencing dependencies.
362 The outer dictionary key groups dependencies by :class:`Package`. |br|
363 The inner dictionary key accesses dependencies by :class:`~pyTooling.Versioning.SemanticVersion`.
365 :returns: Dictionary of dependencies.
366 """
367 return self._dependsOn
369 def AddDependencyToPackageVersion(self, packageVersion: PackageVersion) -> None:
370 """
371 Add a dependency from current package version to another package version.
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}
384 def AddDependencyToPackageVersions(self, packageVersions: Iterable[PackageVersion]) -> None:
385 """
386 Add multiple dependencies from current package version to a list of other package versions.
388 :param packageVersions: Dependencies to be added.
389 """
390 # TODO: check for iterable
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}
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.
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
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#
432 packageVersion = package._versions[v]
433 self.AddDependencyToPackageVersion(packageVersion)
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
441 packageVersion = package._versions[version]
442 self.AddDependencyToPackageVersion(packageVersion)
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.
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
455 def SolveLatest(self) -> Iterable[PackageVersion]:
456 """
457 Solve the dependency problem, while using preferably latest versions.
459 .. todo::
461 Describe algorithm.
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}
468 def _recursion(currentSolution: dict[Package, PackageVersion]) -> bool:
469 """
470 Nested function for recursion.
472 It adds the latest matching version of every package the current solution requires, and recurses until no
473 package is missing.
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())
483 # 2. Identify which required packages are missing from the solution
484 missingPackages = requiredPackages - currentSolution.keys()
486 # Base Case: If no packages are missing, the graph is complete and valid
487 if len(missingPackages) == 0:
488 return True
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))
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
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())
504 if allowedVersions is None:
505 allowedVersions = parentConstraints
506 else:
507 # Intersect with existing constraints (must satisfy everyone)
508 allowedVersions &= parentConstraints
510 # If the intersection is empty, no version satisfies all parents -> backtrack
511 if not allowedVersions:
512 return False
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]
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
530 if isCompatible:
531 # Tentatively add to solution
532 currentSolution[targetPackage] = candidate
534 # Recurse
535 if _recursion(currentSolution):
536 return True
538 # If recursion failed, remove (backtrack) and try next version
539 del currentSolution[targetPackage]
541 # If we run out of versions for this package, this path is dead
542 return False
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}'.")
550 def __len__(self) -> int:
551 """
552 Returns the number of dependencies.
554 :returns: Number of dependencies.
555 """
556 return len(self._dependsOn)
558 def __str__(self) -> str:
559 """
560 Return a string representation of this package version.
562 :returns: The package's name and version.
563 """
564 return f"{self._package._name} - {self._version}"
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.
576 def __init__(self, name: str, *, storage: PackageStorage) -> None:
577 """
578 Initializes a package.
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
589 self._name = name
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
596 self._storage = storage
597 storage._packages[name] = self
599 self._versions = {}
601 @readonly
602 def Storage(self) -> PackageStorage:
603 """
604 Read-only property to access the package's storage.
606 :returns: Package storage.
607 """
608 return self._storage
610 @readonly
611 def Name(self) -> str:
612 """
613 Read-only property to access the package name.
615 :returns: Name of the package.
616 """
617 return self._name
619 @readonly
620 def LatestVersion(self) -> Nullable[PackageVersion]:
621 """
622 Read-only property to return the most recent version of this package.
624 Versions are held newest-first once :meth:`SortVersions` has run, so this is the first of them.
626 :returns: The latest version, or ``None`` if the package has no version yet.
627 """
628 if len(self._versions) == 0:
629 return None
631 return firstValue(self._versions)
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.
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.
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
647 return latest.RepositoryURL
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.
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.
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
662 return latest.DocumentationURL
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.
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.
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
677 return latest.IssueTrackerURL
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.
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.
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
693 return latest.ProjectURL
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.
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.
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
708 return latest.ChangelogURL
710 @readonly
711 def Versions(self) -> dict[SemanticVersion, PackageVersion]:
712 """
713 Read-only property to access the dictionary of available versions.
715 :returns: Available version dictionary.
716 """
717 return self._versions
719 @readonly
720 def VersionCount(self) -> int:
721 """
722 Read-only property to return the number of versions this package has.
724 :returns: Number of versions.
725 """
726 return len(self._versions)
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)}
734 def __len__(self) -> int:
735 """
736 Returns the number of available versions.
738 :returns: Number of versions.
739 """
740 return len(self._versions)
742 def __iter__(self) -> Iterator[PackageVersion]:
743 """
744 Iterate the versions of this package.
746 :returns: An iterator over all versions of this package.
747 """
748 return iter(self._versions.values())
750 def __getitem__(self, version: str | SemanticVersion) -> PackageVersion:
751 """
752 Access a package version in the package by version string or semantic version.
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
766 return self._versions[version]
768 def __str__(self) -> str:
769 """
770 Return a string representation of this package.
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)})"
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.
789 def __init__(self, name: str, graph: PackageDependencyGraph) -> None:
790 """
791 Initializes the package storage.
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
802 self._name = name
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
809 self._graph = graph
810 graph._storages[name] = self
812 self._packages = {}
814 @readonly
815 def Graph(self) -> PackageDependencyGraph:
816 """
817 Read-only property to access the package dependency graph.
819 :returns: Package dependency graph.
820 """
821 return self._graph
823 @readonly
824 def Name(self) -> str:
825 """
826 Read-only property to access the package dependency graph's name.
828 :returns: Name of the package dependency graph.
829 """
830 return self._name
832 @readonly
833 def Packages(self) -> dict[str, Package]:
834 """
835 Read-only property to access the dictionary of known packages.
837 :returns: Known packages dictionary.
838 """
839 return self._packages
841 @readonly
842 def PackageCount(self) -> int:
843 """
844 Read-only property to return the number of packages in this storage.
846 :returns: Number of packages.
847 """
848 return len(self._packages)
850 def CreatePackage(self, packageName: str) -> Package:
851 """
852 Create a new package in the package dependency graph.
854 :param packageName: Name of the new package.
855 :returns: New package's instance.
856 """
857 return Package(packageName, storage=self)
859 def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]:
860 """
861 Create multiple new packages in the package dependency graph.
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]
868 def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion:
869 """
870 Create a new package and a package version in the package dependency graph.
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)
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.
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]
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()
897 def __len__(self) -> int:
898 """
899 Returns the number of known packages.
901 :returns: Number of packages.
902 """
903 return len(self._packages)
905 def __iter__(self) -> Iterator[Package]:
906 """
907 Iterate the packages in this storage.
909 :returns: An iterator over all packages in this storage.
910 """
911 return iter(self._packages.values())
913 def __getitem__(self, name: str) -> Package:
914 """
915 Access a known package in the package dependency graph by package name.
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]
923 def __str__(self) -> str:
924 """
925 Return a string representation of this graph.
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)})"
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.
943 def __init__(self, name: str) -> None:
944 """
945 Initializes the package dependency graph.
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
955 self._name = name
957 self._storages = {}
959 @readonly
960 def Name(self) -> str:
961 """
962 Read-only property to access the package dependency graph's name.
964 :returns: Name of the package dependency graph.
965 """
966 return self._name
968 @readonly
969 def Storages(self) -> dict[str, PackageStorage]:
970 """
971 Read-only property to access the dictionary of known package storages.
973 :returns: Known package storage dictionary.
974 """
975 return self._storages
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]
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()
1024 def __len__(self) -> int:
1025 """
1026 Returns the number of known packages.
1028 :returns: Number of packages.
1029 """
1030 return len(self._storages)
1032 def __iter__(self) -> Iterator[PackageStorage]:
1033 """
1034 Iterate the storages in this dependency graph.
1036 :returns: An iterator over all storages in this dependency graph.
1037 """
1038 return iter(self._storages.values())
1040 def __getitem__(self, name: str) -> PackageStorage:
1041 """
1042 Access a known package storage in the package dependency graph by storage name.
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]
1050 def __str__(self) -> str:
1051 """
1052 Return a string representation of this graph.
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})"