Coverage for pyTooling / Dependency / __init__.py: 81%
261 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-08 23:46 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-08 23:46 +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.
37"""
38from datetime import datetime
39from typing import Optional as Nullable, Dict, Union, Iterable, Set, Self, Iterator
41try:
42 from pyTooling.Decorators import export, readonly
43 from pyTooling.MetaClasses import ExtendedType, abstractmethod, mustoverride
44 from pyTooling.Exceptions import ToolingException
45 from pyTooling.Common import getFullyQualifiedName, firstKey
46 from pyTooling.Versioning import SemanticVersion
47except (ImportError, ModuleNotFoundError): # pragma: no cover
48 print("[pyTooling.Dependency] Could not import from 'pyTooling.*'!")
50 try:
51 from Decorators import export, readonly
52 from MetaClasses import ExtendedType, abstractmethod, mustoverride
53 from Exceptions import ToolingException
54 from Common import getFullyQualifiedName, firstKey
55 from Versioning import SemanticVersion
56 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
57 print("[pyTooling.Dependency] Could not import directly!")
58 raise ex
61@export
62class PackageVersion(metaclass=ExtendedType, slots=True):
63 """
64 The package's version of a :class:`Package`.
66 A :class:`Package` has multiple available versions. A version can have multiple dependencies to other
67 :class:`PackageVersion`s.
68 """
70 _package: "Package" #: Reference to the corresponding package
71 _version: SemanticVersion #: :class:`SemanticVersion` of this package version.
72 _releasedAt: Nullable[datetime]
74 _dependsOn: Dict["Package", Dict[SemanticVersion, "PackageVersion"]] #: Versioned dependencies to other packages.
76 def __init__(self, version: SemanticVersion, package: "Package", releasedAt: Nullable[datetime] = None) -> None:
77 """
78 Initializes a package version.
80 :param version: Semantic version of this package.
81 :param package: Package this version is associated to.
82 :param releasedAt: Optional release date and time.
83 :raises TypeError: When parameter 'version' is not of type 'SemanticVersion'.
84 :raises TypeError: When parameter 'package' is not of type 'Package'.
85 :raises TypeError: When parameter 'releasedAt' is not of type 'datetime'.
86 :raises ToolingException: When version already exists for the associated package.
87 """
88 if not isinstance(version, SemanticVersion): 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true
89 ex = TypeError("Parameter 'version' is not of type 'SemanticVersion'.")
90 ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.")
91 raise ex
92 elif version in package._versions: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 raise ToolingException(f"Version '{version}' is already registered in package '{package._name}'.")
95 self._version = version
96 package._versions[version] = self
98 if not isinstance(package, Package): 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 ex = TypeError("Parameter 'package' is not of type 'Package'.")
100 ex.add_note(f"Got type '{getFullyQualifiedName(package)}'.")
101 raise ex
103 self._package = package
105 if releasedAt is not None and not isinstance(releasedAt, datetime): 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 ex = TypeError("Parameter 'releasedAt' is not of type 'datetime'.")
107 ex.add_note(f"Got type '{getFullyQualifiedName(releasedAt)}'.")
108 raise ex
110 self._releasedAt = releasedAt
112 self._dependsOn = {}
114 @readonly
115 def Package(self) -> "Package":
116 """
117 Read-only property to access the associated package.
119 :returns: Associated package.
120 """
121 return self._package
123 @readonly
124 def Version(self) -> SemanticVersion:
125 """
126 Read-only property to access the semantic version of a package.
128 :returns: Semantic version of a package.
129 """
130 return self._version
132 @readonly
133 def ReleasedAt(self) -> Nullable[datetime]:
134 """
135 Read-only property to access the release date and time.
137 :returns: Optional release date and time.
138 """
139 return self._releasedAt
141 @readonly
142 def DependsOn(self) -> Dict["Package", Dict[SemanticVersion, "PackageVersion"]]:
143 """
144 Read-only property to access the dictionary of dictionaries referencing dependencies.
146 The outer dictionary key groups dependencies by :class:`Package`. |br|
147 The inner dictionary key accesses dependencies by :class:`~pyTooling.Versioning.SemanticVersion`.
149 :returns: Dictionary of dependencies.
150 """
151 return self._dependsOn
153 def AddDependencyToPackageVersion(self, packageVersion: "PackageVersion") -> None:
154 """
155 Add a dependency from current package version to another package version.
157 :param packageVersion: Dependency to be added.
158 """
159 if (package := packageVersion._package) in self._dependsOn:
160 pack = self._dependsOn[package]
161 if (version := packageVersion._version) in pack: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true
162 pass
163 else:
164 pack[version] = packageVersion
165 else:
166 self._dependsOn[package] = {packageVersion._version: packageVersion}
168 def AddDependencyToPackageVersions(self, packageVersions: Iterable["PackageVersion"]) -> None:
169 """
170 Add multiple dependencies from current package version to a list of other package versions.
172 :param packageVersions: Dependencies to be added.
173 """
174 # TODO: check for iterable
176 for packageVersion in packageVersions:
177 if (package := packageVersion._package) in self._dependsOn:
178 pack = self._dependsOn[package]
179 if (version := packageVersion._version) in pack: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 pass
181 else:
182 pack[version] = packageVersion
183 else:
184 self._dependsOn[package] = {packageVersion._version: packageVersion}
186 def AddDependencyTo(
187 self,
188 package: Union[str, Package],
189 version: Union[str, SemanticVersion, Iterable[Union[str, SemanticVersion]]]
190 ) -> None:
191 """
192 Add a dependency from current package version to another package version.
194 :param package: :class:`Package` object or name of the package.
195 :param version: :class:`~pyTooling.Versioning.SemanticVersion` object or version string or an iterable thereof.
196 :return:
197 """
198 if isinstance(package, str):
199 package = self._package._storage._packages[package]
200 elif not isinstance(package, Package): 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true
201 ex = TypeError(f"Parameter 'package' is not of type 'str' nor 'Package'.")
202 ex.add_note(f"Got type '{getFullyQualifiedName(package)}'.")
203 raise ex
205 if isinstance(version, str):
206 version = SemanticVersion.Parse(version)
207 elif isinstance(version, Iterable):
208 for v in version:
209 if isinstance(v, str): 209 ↛ 211line 209 didn't jump to line 211 because the condition on line 209 was always true
210 v = SemanticVersion.Parse(v)
211 elif not isinstance(v, SemanticVersion):
212 ex = TypeError(f"Parameter 'version' contains an element, which is not of type 'str' nor 'SemanticVersion'.")
213 ex.add_note(f"Got type '{getFullyQualifiedName(v)}'.")
214 raise ex#
216 packageVersion = package._versions[v]
217 self.AddDependencyToPackageVersion(packageVersion)
219 return
220 elif not isinstance(version, SemanticVersion): 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
221 ex = TypeError(f"Parameter 'version' is not of type 'str' nor 'SemanticVersion'.")
222 ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.")
223 raise ex
225 packageVersion = package._versions[version]
226 self.AddDependencyToPackageVersion(packageVersion)
228 def SortDependencies(self) -> Self:
229 """
230 Sort versions of a package and dependencies by version, thus dependency resolution can work on pre-sorted lists and
231 dictionaries.
233 :returns: The instance itself (for method-chaining).
234 """
235 for package, versions in self._dependsOn.items():
236 self._dependsOn[package] = {version: versions[version] for version in sorted(versions.keys(), reverse=True)}
237 return self
239 def SolveLatest(self) -> Iterable["PackageVersion"]:
240 """
241 Solve the dependency problem, while using preferably latest versions.
243 .. todo::
245 Describe algorithm.
247 :returns: A list of :class:`PackageVersion`s fulfilling the constraints of the dependency problem.
248 :raises ToolingException: When there is no valid solution to the problem.
249 """
250 solution: Dict["Package", "PackageVersion"] = {self._package: self}
252 def _recursion(currentSolution: Dict["Package", "PackageVersion"]) -> bool:
253 # 1. Identify all required packages based on current selection
254 requiredPackages: Set["Package"] = set()
255 for packageVersion in currentSolution.values():
256 requiredPackages.update(packageVersion.DependsOn.keys())
258 # 2. Identify which required packages are missing from the solution
259 missingPackages = requiredPackages - currentSolution.keys()
261 # Base Case: If no packages are missing, the graph is complete and valid
262 if len(missingPackages) == 0:
263 return True
265 # 3. Pick the next package to resolve
266 # (Heuristic: we just pick the first one, but could be optimized)
267 targetPackage = next(iter(missingPackages))
269 # 4. Determine valid candidates
270 # The candidate version must satisfy the constraints of all parents currently in the solution
271 allowedVersions: Nullable[Set[SemanticVersion]] = None
273 for parentPackageVersion in currentSolution.values():
274 if targetPackage in parentPackageVersion.DependsOn:
275 # Get the set of versions allowed by this specific parent
276 # (Keys of the inner dict are SemanticVersion objects)
277 parentConstraints = set(parentPackageVersion.DependsOn[targetPackage].keys())
279 if allowedVersions is None:
280 allowedVersions = parentConstraints
281 else:
282 # Intersect with existing constraints (must satisfy everyone)
283 allowedVersions &= parentConstraints
285 # If the intersection is empty, no version satisfies all parents -> backtrack
286 if not allowedVersions:
287 return False
289 # 5. Try candidates (sorted descending to prioritize latest)
290 # We convert the set to a list and sort it reverse
291 for version_key in sorted(list(allowedVersions), reverse=True):
292 candidate = targetPackage.Versions[version_key]
294 # 6. Check compatibility (reverse dependencies)
295 # Does the candidate depend on anything we have already selected?
296 # If so, does the candidate accept the version we already picked?
297 isCompatible = True
298 for existingPackage, existingPackageVersion in currentSolution.items():
299 if existingPackage in candidate.DependsOn:
300 # If candidate relies on 'existingPackage', check if 'existingPackageVersion' is in the allowed list
301 if existingPackageVersion._version not in candidate.DependsOn[existingPackage]:
302 isCompatible = False
303 break
305 if isCompatible:
306 # Tentatively add to solution
307 currentSolution[targetPackage] = candidate
309 # Recurse
310 if _recursion(currentSolution):
311 return True
313 # If recursion failed, remove (backtrack) and try next version
314 del currentSolution[targetPackage]
316 # If we run out of versions for this package, this path is dead
317 return False
319 # Run the solver
320 if _recursion(solution):
321 return list(solution.values())
322 else:
323 raise ToolingException(f"Could not resolve dependencies for '{self}'.")
325 def __len__(self) -> int:
326 """
327 Returns the number of dependencies.
329 :returns: Number of dependencies.
330 """
331 return len(self._dependsOn)
333 def __str__(self) -> str:
334 """
335 Return a string representation of this package version.
337 :returns: The package's name and version.
338 """
339 return f"{self._package._name} - {self._version}"
342@export
343class Package(metaclass=ExtendedType, slots=True):
344 """
345 The package, which exists in multiple versions (:class:`PackageVersion`).
346 """
347 _storage: "PackageStorage" #: Reference to the package's storage.
348 _name: str #: Name of the package.
350 _versions: Dict[SemanticVersion, PackageVersion] #: A dictionary of available versions for this package.
352 def __init__(self, name: str, *, storage: "PackageStorage") -> None:
353 """
354 Initializes a package.
356 :param name: Name of the package.
357 :param storage: The package's storage.
358 """
359 if not isinstance(name, str): 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true
360 ex = TypeError("Parameter 'name' is not of type 'str'.")
361 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
362 raise ex
364 self._name = name
366 if not isinstance(storage, PackageStorage): 366 ↛ 367line 366 didn't jump to line 367 because the condition on line 366 was never true
367 ex = TypeError("Parameter 'storage' is not of type 'PackageStorage'.")
368 ex.add_note(f"Got type '{getFullyQualifiedName(storage)}'.")
369 raise ex
371 self._storage = storage
372 storage._packages[name] = self
374 self._versions = {}
376 @readonly
377 def Storage(self) -> "PackageStorage":
378 """
379 Read-only property to access the package's storage.
381 :returns: Package storage.
382 """
383 return self._storage
385 @readonly
386 def Name(self) -> str:
387 """
388 Read-only property to access the package name.
390 :returns: Name of the package.
391 """
392 return self._name
394 @readonly
395 def Versions(self) -> Dict[SemanticVersion, PackageVersion]:
396 """
397 Read-only property to access the dictionary of available versions.
399 :returns: Available version dictionary.
400 """
401 return self._versions
403 @readonly
404 def VersionCount(self) -> int:
405 return len(self._versions)
407 def SortVersions(self) -> None:
408 """
409 Sort versions within this package in reverse order (latest first).
410 """
411 self._versions = {k: self._versions[k].SortDependencies() for k in sorted(self._versions.keys(), reverse=True)}
413 def __len__(self) -> int:
414 """
415 Returns the number of available versions.
417 :returns: Number of versions.
418 """
419 return len(self._versions)
421 def __iter__(self) -> Iterator[PackageVersion]:
422 return iter(self._versions.values())
424 def __getitem__(self, version: Union[str, SemanticVersion]) -> PackageVersion:
425 """
426 Access a package version in the package by version string or semantic version.
428 :param version: Version as string or instance.
429 :returns: The package version.
430 :raises KeyError: If version is not available for the package.
431 """
432 if isinstance(version, str): 432 ↛ 434line 432 didn't jump to line 434 because the condition on line 432 was always true
433 version = SemanticVersion.Parse(version)
434 elif not isinstance(version, SemanticVersion):
435 # TODO: raise proper type error
436 raise TypeError()
438 return self._versions[version]
440 def __str__(self) -> str:
441 """
442 Return a string representation of this package.
444 :returns: The package's name and latest version.
445 """
446 if len(self._versions) == 0:
447 return f"{self._name} (empty)"
448 else:
449 return f"{self._name} (latest: {firstKey(self._versions)})"
452@export
453class PackageStorage(metaclass=ExtendedType, slots=True):
454 """
455 A storage for packages.
456 """
457 _graph: "PackageDependencyGraph" #: Reference to the overall dependency graph data structure.
458 _name: str #: Package dependency graph name
459 _packages: Dict[str, Package] #: Dictionary of known packages.
461 def __init__(self, name: str, graph: "PackageDependencyGraph") -> None:
462 """
463 Initializes the package storage.
465 :param name: Name of the package storage.
466 :param graph: PackageDependencyGraph instance (parent).
467 """
468 if not isinstance(name, str): 468 ↛ 469line 468 didn't jump to line 469 because the condition on line 468 was never true
469 ex = TypeError("Parameter 'name' is not of type 'str'.")
470 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
471 raise ex
473 self._name = name
475 if not isinstance(graph, PackageDependencyGraph): 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true
476 ex = TypeError("Parameter 'graph' is not of type 'PackageDependencyGraph'.")
477 ex.add_note(f"Got type '{getFullyQualifiedName(graph)}'.")
478 raise ex
480 self._graph = graph
481 graph._storages[name] = self
483 self._packages = {}
485 @readonly
486 def Graph(self) -> "PackageDependencyGraph":
487 """
488 Read-only property to access the package dependency graph.
490 :returns: Package dependency graph.
491 """
492 return self._graph
494 @readonly
495 def Name(self) -> str:
496 """
497 Read-only property to access the package dependency graph's name.
499 :returns: Name of the package dependency graph.
500 """
501 return self._name
503 @readonly
504 def Packages(self) -> Dict[str, Package]:
505 """
506 Read-only property to access the dictionary of known packages.
508 :returns: Known packages dictionary.
509 """
510 return self._packages
512 @readonly
513 def PackageCount(self) -> int:
514 return len(self._packages)
516 def CreatePackage(self, packageName: str) -> Package:
517 """
518 Create a new package in the package dependency graph.
520 :param packageName: Name of the new package.
521 :returns: New package's instance.
522 """
523 return Package(packageName, storage=self)
525 def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]:
526 """
527 Create multiple new packages in the package dependency graph.
529 :param packageNames: List of package names.
530 :returns: List of new package instances.
531 """
532 return [Package(packageName, storage=self) for packageName in packageNames]
534 def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion:
535 """
536 Create a new package and a package version in the package dependency graph.
538 :param packageName: Name of the new package.
539 :param version: Version string.
540 :returns: New package version instance.
541 """
542 package = Package(packageName, storage=self)
543 return PackageVersion(SemanticVersion.Parse(version), package)
545 def CreatePackageVersions(self, packageName: str, versions: Iterable[str]) -> Iterable[PackageVersion]:
546 """
547 Create a new package and multiple package versions in the package dependency graph.
549 :param packageName: Name of the new package.
550 :param versions: List of version string.s
551 :returns: List of new package version instances.
552 """
553 package = Package(packageName, storage=self)
554 return [PackageVersion(SemanticVersion.Parse(version), package) for version in versions]
556 def SortPackageVersions(self) -> None:
557 """
558 Sort versions within all known packages in reverse order (latest first).
559 """
560 for package in self._packages.values():
561 package.SortVersions()
563 def __len__(self) -> int:
564 """
565 Returns the number of known packages.
567 :returns: Number of packages.
568 """
569 return len(self._packages)
571 def __iter__(self) -> Iterator[Package]:
572 return iter(self._packages.values())
574 def __getitem__(self, name: str) -> Package:
575 """
576 Access a known package in the package dependency graph by package name.
578 :param name: Name of the package.
579 :returns: The package.
580 :raises KeyError: If package is not known within the package dependency graph.
581 """
582 return self._packages[name]
584 def __str__(self) -> str:
585 """
586 Return a string representation of this graph.
588 :returns: The graph's name and number of known packages.
589 """
590 if len(self._packages) == 0: 590 ↛ 593line 590 didn't jump to line 593 because the condition on line 590 was always true
591 return f"{self._name} (empty)"
592 else:
593 return f"{self._name} ({len(self._packages)})"
596@export
597class PackageDependencyGraph(metaclass=ExtendedType, slots=True):
598 """
599 A package dependency graph collecting all known packages.
600 """
601 _name: str #: Package dependency graph name
602 _storages: Dict[str, PackageStorage] #: Dictionary of known package storages.
604 def __init__(self, name: str) -> None:
605 """
606 Initializes the package dependency graph.
608 :param name: Name of the dependency graph.
609 """
610 if not isinstance(name, str): 610 ↛ 611line 610 didn't jump to line 611 because the condition on line 610 was never true
611 ex = TypeError("Parameter 'name' is not of type 'str'.")
612 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
613 raise ex
615 self._name = name
617 self._storages = {}
619 @readonly
620 def Name(self) -> str:
621 """
622 Read-only property to access the package dependency graph's name.
624 :returns: Name of the package dependency graph.
625 """
626 return self._name
628 @readonly
629 def Storages(self) -> Dict[str, PackageStorage]:
630 """
631 Read-only property to access the dictionary of known package storages.
633 :returns: Known package storage dictionary.
634 """
635 return self._storages
637 # def CreatePackage(self, packageName: str) -> Package:
638 # """
639 # Create a new package in the package dependency graph.
640 #
641 # :param packageName: Name of the new package.
642 # :returns: New package's instance.
643 # """
644 # return Package(packageName, storage=self)
645 #
646 # def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]:
647 # """
648 # Create multiple new packages in the package dependency graph.
649 #
650 # :param packageNames: List of package names.
651 # :returns: List of new package instances.
652 # """
653 # return [Package(packageName, storage=self) for packageName in packageNames]
654 #
655 # def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion:
656 # """
657 # Create a new package and a package version in the package dependency graph.
658 #
659 # :param packageName: Name of the new package.
660 # :param version: Version string.
661 # :returns: New package version instance.
662 # """
663 # package = Package(packageName, storage=self)
664 # return PackageVersion(SemanticVersion.Parse(version), package)
665 #
666 # def CreatePackageVersions(self, packageName: str, versions: Iterable[str]) -> Iterable[PackageVersion]:
667 # """
668 # Create a new package and multiple package versions in the package dependency graph.
669 #
670 # :param packageName: Name of the new package.
671 # :param versions: List of version string.s
672 # :returns: List of new package version instances.
673 # """
674 # package = Package(packageName, storage=self)
675 # return [PackageVersion(SemanticVersion.Parse(version), package) for version in versions]
677 def SortPackageVersions(self) -> None:
678 """
679 Sort versions within all known packages in reverse order (latest first).
680 """
681 for storage in self._storages.values():
682 storage.SortPackageVersions()
684 def __len__(self) -> int:
685 """
686 Returns the number of known packages.
688 :returns: Number of packages.
689 """
690 return len(self._storages)
692 def __iter__(self) -> Iterator[PackageStorage]:
693 return iter(self._storages.values())
695 def __getitem__(self, name: str) -> PackageStorage:
696 """
697 Access a known package storage in the package dependency graph by storage name.
699 :param name: Name of the package storage.
700 :returns: The package storage.
701 :raises KeyError: If package storage is not known within the package dependency graph.
702 """
703 return self._storages[name]
705 def __str__(self) -> str:
706 """
707 Return a string representation of this graph.
709 :returns: The graph's name and number of known packages.
710 """
711 count = sum(len(storage) for storage in self._storages.values())
712 if count == 0: 712 ↛ 715line 712 didn't jump to line 715 because the condition on line 712 was always true
713 return f"{self._name} (empty)"
714 else:
715 return f"{self._name} ({count})"