Coverage for pyTooling / Dependency / __init__.py: 81%
260 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-13 22:36 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-13 22:36 +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
41from pyTooling.Decorators import export, readonly
42from pyTooling.MetaClasses import ExtendedType
43from pyTooling.Exceptions import ToolingException
44from pyTooling.Common import getFullyQualifiedName, firstKey
45from pyTooling.Versioning import SemanticVersion
48@export
49class PackageVersion(metaclass=ExtendedType, slots=True):
50 """
51 The package's version of a :class:`Package`.
53 A :class:`Package` has multiple available versions. A version can have multiple dependencies to other
54 :class:`PackageVersion`s.
55 """
57 _package: "Package" #: Reference to the corresponding package
58 _version: SemanticVersion #: :class:`SemanticVersion` of this package version.
59 _releasedAt: Nullable[datetime]
61 _dependsOn: Dict["Package", Dict[SemanticVersion, "PackageVersion"]] #: Versioned dependencies to other packages.
63 def __init__(self, version: SemanticVersion, package: "Package", releasedAt: Nullable[datetime] = None) -> None:
64 """
65 Initializes a package version.
67 :param version: Semantic version of this package.
68 :param package: Package this version is associated to.
69 :param releasedAt: Optional release date and time.
70 :raises TypeError: When parameter 'version' is not of type 'SemanticVersion'.
71 :raises TypeError: When parameter 'package' is not of type 'Package'.
72 :raises TypeError: When parameter 'releasedAt' is not of type 'datetime'.
73 :raises ToolingException: When version already exists for the associated package.
74 """
75 if not isinstance(version, SemanticVersion): 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true
76 ex = TypeError("Parameter 'version' is not of type 'SemanticVersion'.")
77 ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.")
78 raise ex
79 elif version in package._versions: 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true
80 raise ToolingException(f"Version '{version}' is already registered in package '{package._name}'.")
82 self._version = version
83 package._versions[version] = self
85 if not isinstance(package, Package): 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true
86 ex = TypeError("Parameter 'package' is not of type 'Package'.")
87 ex.add_note(f"Got type '{getFullyQualifiedName(package)}'.")
88 raise ex
90 self._package = package
92 if releasedAt is not None and not isinstance(releasedAt, datetime): 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 ex = TypeError("Parameter 'releasedAt' is not of type 'datetime'.")
94 ex.add_note(f"Got type '{getFullyQualifiedName(releasedAt)}'.")
95 raise ex
97 self._releasedAt = releasedAt
99 self._dependsOn = {}
101 @readonly
102 def Package(self) -> "Package":
103 """
104 Read-only property to access the associated package.
106 :returns: Associated package.
107 """
108 return self._package
110 @readonly
111 def Version(self) -> SemanticVersion:
112 """
113 Read-only property to access the semantic version of a package.
115 :returns: Semantic version of a package.
116 """
117 return self._version
119 @readonly
120 def ReleasedAt(self) -> Nullable[datetime]:
121 """
122 Read-only property to access the release date and time.
124 :returns: Optional release date and time.
125 """
126 return self._releasedAt
128 @readonly
129 def DependsOn(self) -> Dict["Package", Dict[SemanticVersion, "PackageVersion"]]:
130 """
131 Read-only property to access the dictionary of dictionaries referencing dependencies.
133 The outer dictionary key groups dependencies by :class:`Package`. |br|
134 The inner dictionary key accesses dependencies by :class:`~pyTooling.Versioning.SemanticVersion`.
136 :returns: Dictionary of dependencies.
137 """
138 return self._dependsOn
140 def AddDependencyToPackageVersion(self, packageVersion: "PackageVersion") -> None:
141 """
142 Add a dependency from current package version to another package version.
144 :param packageVersion: Dependency to be added.
145 """
146 if (package := packageVersion._package) in self._dependsOn:
147 pack = self._dependsOn[package]
148 if (version := packageVersion._version) in pack: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
149 pass
150 else:
151 pack[version] = packageVersion
152 else:
153 self._dependsOn[package] = {packageVersion._version: packageVersion}
155 def AddDependencyToPackageVersions(self, packageVersions: Iterable["PackageVersion"]) -> None:
156 """
157 Add multiple dependencies from current package version to a list of other package versions.
159 :param packageVersions: Dependencies to be added.
160 """
161 # TODO: check for iterable
163 for packageVersion in packageVersions:
164 if (package := packageVersion._package) in self._dependsOn:
165 pack = self._dependsOn[package]
166 if (version := packageVersion._version) in pack: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 pass
168 else:
169 pack[version] = packageVersion
170 else:
171 self._dependsOn[package] = {packageVersion._version: packageVersion}
173 def AddDependencyTo(
174 self,
175 package: Union[str, Package],
176 version: Union[str, SemanticVersion, Iterable[Union[str, SemanticVersion]]]
177 ) -> None:
178 """
179 Add a dependency from current package version to another package version.
181 :param package: :class:`Package` object or name of the package.
182 :param version: :class:`~pyTooling.Versioning.SemanticVersion` object or version string or an iterable thereof.
183 :return:
184 """
185 if isinstance(package, str):
186 package = self._package._storage._packages[package]
187 elif not isinstance(package, Package): 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 ex = TypeError(f"Parameter 'package' is not of type 'str' nor 'Package'.")
189 ex.add_note(f"Got type '{getFullyQualifiedName(package)}'.")
190 raise ex
192 if isinstance(version, str):
193 version = SemanticVersion.Parse(version)
194 elif isinstance(version, Iterable):
195 for v in version:
196 if isinstance(v, str): 196 ↛ 198line 196 didn't jump to line 198 because the condition on line 196 was always true
197 v = SemanticVersion.Parse(v)
198 elif not isinstance(v, SemanticVersion):
199 ex = TypeError(f"Parameter 'version' contains an element, which is not of type 'str' nor 'SemanticVersion'.")
200 ex.add_note(f"Got type '{getFullyQualifiedName(v)}'.")
201 raise ex#
203 packageVersion = package._versions[v]
204 self.AddDependencyToPackageVersion(packageVersion)
206 return
207 elif not isinstance(version, SemanticVersion): 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 ex = TypeError(f"Parameter 'version' is not of type 'str' nor 'SemanticVersion'.")
209 ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.")
210 raise ex
212 packageVersion = package._versions[version]
213 self.AddDependencyToPackageVersion(packageVersion)
215 def SortDependencies(self) -> Self:
216 """
217 Sort versions of a package and dependencies by version, thus dependency resolution can work on pre-sorted lists and
218 dictionaries.
220 :returns: The instance itself (for method-chaining).
221 """
222 for package, versions in self._dependsOn.items():
223 self._dependsOn[package] = {version: versions[version] for version in sorted(versions.keys(), reverse=True)}
224 return self
226 def SolveLatest(self) -> Iterable["PackageVersion"]:
227 """
228 Solve the dependency problem, while using preferably latest versions.
230 .. todo::
232 Describe algorithm.
234 :returns: A list of :class:`PackageVersion`s fulfilling the constraints of the dependency problem.
235 :raises ToolingException: When there is no valid solution to the problem.
236 """
237 solution: Dict["Package", "PackageVersion"] = {self._package: self}
239 def _recursion(currentSolution: Dict["Package", "PackageVersion"]) -> bool:
240 # 1. Identify all required packages based on current selection
241 requiredPackages: Set["Package"] = set()
242 for packageVersion in currentSolution.values():
243 requiredPackages.update(packageVersion.DependsOn.keys())
245 # 2. Identify which required packages are missing from the solution
246 missingPackages = requiredPackages - currentSolution.keys()
248 # Base Case: If no packages are missing, the graph is complete and valid
249 if len(missingPackages) == 0:
250 return True
252 # 3. Pick the next package to resolve
253 # (Heuristic: we just pick the first one, but could be optimized)
254 targetPackage = next(iter(missingPackages))
256 # 4. Determine valid candidates
257 # The candidate version must satisfy the constraints of all parents currently in the solution
258 allowedVersions: Nullable[Set[SemanticVersion]] = None
260 for parentPackageVersion in currentSolution.values():
261 if targetPackage in parentPackageVersion.DependsOn:
262 # Get the set of versions allowed by this specific parent
263 # (Keys of the inner dict are SemanticVersion objects)
264 parentConstraints = set(parentPackageVersion.DependsOn[targetPackage].keys())
266 if allowedVersions is None:
267 allowedVersions = parentConstraints
268 else:
269 # Intersect with existing constraints (must satisfy everyone)
270 allowedVersions &= parentConstraints
272 # If the intersection is empty, no version satisfies all parents -> backtrack
273 if not allowedVersions:
274 return False
276 # 5. Try candidates (sorted descending to prioritize latest)
277 # We convert the set to a list and sort it reverse
278 for version_key in sorted(list(allowedVersions), reverse=True):
279 candidate = targetPackage.Versions[version_key]
281 # 6. Check compatibility (reverse dependencies)
282 # Does the candidate depend on anything we have already selected?
283 # If so, does the candidate accept the version we already picked?
284 isCompatible = True
285 for existingPackage, existingPackageVersion in currentSolution.items():
286 if existingPackage in candidate.DependsOn:
287 # If candidate relies on 'existingPackage', check if 'existingPackageVersion' is in the allowed list
288 if existingPackageVersion._version not in candidate.DependsOn[existingPackage]:
289 isCompatible = False
290 break
292 if isCompatible:
293 # Tentatively add to solution
294 currentSolution[targetPackage] = candidate
296 # Recurse
297 if _recursion(currentSolution):
298 return True
300 # If recursion failed, remove (backtrack) and try next version
301 del currentSolution[targetPackage]
303 # If we run out of versions for this package, this path is dead
304 return False
306 # Run the solver
307 if _recursion(solution):
308 return list(solution.values())
309 else:
310 raise ToolingException(f"Could not resolve dependencies for '{self}'.")
312 def __len__(self) -> int:
313 """
314 Returns the number of dependencies.
316 :returns: Number of dependencies.
317 """
318 return len(self._dependsOn)
320 def __str__(self) -> str:
321 """
322 Return a string representation of this package version.
324 :returns: The package's name and version.
325 """
326 return f"{self._package._name} - {self._version}"
329@export
330class Package(metaclass=ExtendedType, slots=True):
331 """
332 The package, which exists in multiple versions (:class:`PackageVersion`).
333 """
334 _storage: "PackageStorage" #: Reference to the package's storage.
335 _name: str #: Name of the package.
337 _versions: Dict[SemanticVersion, PackageVersion] #: A dictionary of available versions for this package.
339 def __init__(self, name: str, *, storage: "PackageStorage") -> None:
340 """
341 Initializes a package.
343 :param name: Name of the package.
344 :param storage: The package's storage.
345 """
346 if not isinstance(name, str): 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true
347 ex = TypeError("Parameter 'name' is not of type 'str'.")
348 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
349 raise ex
351 self._name = name
353 if not isinstance(storage, PackageStorage): 353 ↛ 354line 353 didn't jump to line 354 because the condition on line 353 was never true
354 ex = TypeError("Parameter 'storage' is not of type 'PackageStorage'.")
355 ex.add_note(f"Got type '{getFullyQualifiedName(storage)}'.")
356 raise ex
358 self._storage = storage
359 storage._packages[name] = self
361 self._versions = {}
363 @readonly
364 def Storage(self) -> "PackageStorage":
365 """
366 Read-only property to access the package's storage.
368 :returns: Package storage.
369 """
370 return self._storage
372 @readonly
373 def Name(self) -> str:
374 """
375 Read-only property to access the package name.
377 :returns: Name of the package.
378 """
379 return self._name
381 @readonly
382 def Versions(self) -> Dict[SemanticVersion, PackageVersion]:
383 """
384 Read-only property to access the dictionary of available versions.
386 :returns: Available version dictionary.
387 """
388 return self._versions
390 @readonly
391 def VersionCount(self) -> int:
392 return len(self._versions)
394 def SortVersions(self) -> None:
395 """
396 Sort versions within this package in reverse order (latest first).
397 """
398 self._versions = {k: self._versions[k].SortDependencies() for k in sorted(self._versions.keys(), reverse=True)}
400 def __len__(self) -> int:
401 """
402 Returns the number of available versions.
404 :returns: Number of versions.
405 """
406 return len(self._versions)
408 def __iter__(self) -> Iterator[PackageVersion]:
409 return iter(self._versions.values())
411 def __getitem__(self, version: Union[str, SemanticVersion]) -> PackageVersion:
412 """
413 Access a package version in the package by version string or semantic version.
415 :param version: Version as string or instance.
416 :returns: The package version.
417 :raises KeyError: If version is not available for the package.
418 """
419 if isinstance(version, str): 419 ↛ 421line 419 didn't jump to line 421 because the condition on line 419 was always true
420 version = SemanticVersion.Parse(version)
421 elif not isinstance(version, SemanticVersion):
422 # TODO: raise proper type error
423 raise TypeError()
425 return self._versions[version]
427 def __str__(self) -> str:
428 """
429 Return a string representation of this package.
431 :returns: The package's name and latest version.
432 """
433 if len(self._versions) == 0:
434 return f"{self._name} (empty)"
435 else:
436 return f"{self._name} (latest: {firstKey(self._versions)})"
439@export
440class PackageStorage(metaclass=ExtendedType, slots=True):
441 """
442 A storage for packages.
443 """
444 _graph: "PackageDependencyGraph" #: Reference to the overall dependency graph data structure.
445 _name: str #: Package dependency graph name
446 _packages: Dict[str, Package] #: Dictionary of known packages.
448 def __init__(self, name: str, graph: "PackageDependencyGraph") -> None:
449 """
450 Initializes the package storage.
452 :param name: Name of the package storage.
453 :param graph: PackageDependencyGraph instance (parent).
454 """
455 if not isinstance(name, str): 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true
456 ex = TypeError("Parameter 'name' is not of type 'str'.")
457 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
458 raise ex
460 self._name = name
462 if not isinstance(graph, PackageDependencyGraph): 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true
463 ex = TypeError("Parameter 'graph' is not of type 'PackageDependencyGraph'.")
464 ex.add_note(f"Got type '{getFullyQualifiedName(graph)}'.")
465 raise ex
467 self._graph = graph
468 graph._storages[name] = self
470 self._packages = {}
472 @readonly
473 def Graph(self) -> "PackageDependencyGraph":
474 """
475 Read-only property to access the package dependency graph.
477 :returns: Package dependency graph.
478 """
479 return self._graph
481 @readonly
482 def Name(self) -> str:
483 """
484 Read-only property to access the package dependency graph's name.
486 :returns: Name of the package dependency graph.
487 """
488 return self._name
490 @readonly
491 def Packages(self) -> Dict[str, Package]:
492 """
493 Read-only property to access the dictionary of known packages.
495 :returns: Known packages dictionary.
496 """
497 return self._packages
499 @readonly
500 def PackageCount(self) -> int:
501 return len(self._packages)
503 def CreatePackage(self, packageName: str) -> Package:
504 """
505 Create a new package in the package dependency graph.
507 :param packageName: Name of the new package.
508 :returns: New package's instance.
509 """
510 return Package(packageName, storage=self)
512 def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]:
513 """
514 Create multiple new packages in the package dependency graph.
516 :param packageNames: List of package names.
517 :returns: List of new package instances.
518 """
519 return [Package(packageName, storage=self) for packageName in packageNames]
521 def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion:
522 """
523 Create a new package and a package version in the package dependency graph.
525 :param packageName: Name of the new package.
526 :param version: Version string.
527 :returns: New package version instance.
528 """
529 package = Package(packageName, storage=self)
530 return PackageVersion(SemanticVersion.Parse(version), package)
532 def CreatePackageVersions(self, packageName: str, versions: Iterable[str]) -> Iterable[PackageVersion]:
533 """
534 Create a new package and multiple package versions in the package dependency graph.
536 :param packageName: Name of the new package.
537 :param versions: List of version string.s
538 :returns: List of new package version instances.
539 """
540 package = Package(packageName, storage=self)
541 return [PackageVersion(SemanticVersion.Parse(version), package) for version in versions]
543 def SortPackageVersions(self) -> None:
544 """
545 Sort versions within all known packages in reverse order (latest first).
546 """
547 for package in self._packages.values():
548 package.SortVersions()
550 def __len__(self) -> int:
551 """
552 Returns the number of known packages.
554 :returns: Number of packages.
555 """
556 return len(self._packages)
558 def __iter__(self) -> Iterator[Package]:
559 return iter(self._packages.values())
561 def __getitem__(self, name: str) -> Package:
562 """
563 Access a known package in the package dependency graph by package name.
565 :param name: Name of the package.
566 :returns: The package.
567 :raises KeyError: If package is not known within the package dependency graph.
568 """
569 return self._packages[name]
571 def __str__(self) -> str:
572 """
573 Return a string representation of this graph.
575 :returns: The graph's name and number of known packages.
576 """
577 if len(self._packages) == 0: 577 ↛ 580line 577 didn't jump to line 580 because the condition on line 577 was always true
578 return f"{self._name} (empty)"
579 else:
580 return f"{self._name} ({len(self._packages)})"
583@export
584class PackageDependencyGraph(metaclass=ExtendedType, slots=True):
585 """
586 A package dependency graph collecting all known packages.
587 """
588 _name: str #: Package dependency graph name
589 _storages: Dict[str, PackageStorage] #: Dictionary of known package storages.
591 def __init__(self, name: str) -> None:
592 """
593 Initializes the package dependency graph.
595 :param name: Name of the dependency graph.
596 """
597 if not isinstance(name, str): 597 ↛ 598line 597 didn't jump to line 598 because the condition on line 597 was never true
598 ex = TypeError("Parameter 'name' is not of type 'str'.")
599 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
600 raise ex
602 self._name = name
604 self._storages = {}
606 @readonly
607 def Name(self) -> str:
608 """
609 Read-only property to access the package dependency graph's name.
611 :returns: Name of the package dependency graph.
612 """
613 return self._name
615 @readonly
616 def Storages(self) -> Dict[str, PackageStorage]:
617 """
618 Read-only property to access the dictionary of known package storages.
620 :returns: Known package storage dictionary.
621 """
622 return self._storages
624 # def CreatePackage(self, packageName: str) -> Package:
625 # """
626 # Create a new package in the package dependency graph.
627 #
628 # :param packageName: Name of the new package.
629 # :returns: New package's instance.
630 # """
631 # return Package(packageName, storage=self)
632 #
633 # def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]:
634 # """
635 # Create multiple new packages in the package dependency graph.
636 #
637 # :param packageNames: List of package names.
638 # :returns: List of new package instances.
639 # """
640 # return [Package(packageName, storage=self) for packageName in packageNames]
641 #
642 # def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion:
643 # """
644 # Create a new package and a package version in the package dependency graph.
645 #
646 # :param packageName: Name of the new package.
647 # :param version: Version string.
648 # :returns: New package version instance.
649 # """
650 # package = Package(packageName, storage=self)
651 # return PackageVersion(SemanticVersion.Parse(version), package)
652 #
653 # def CreatePackageVersions(self, packageName: str, versions: Iterable[str]) -> Iterable[PackageVersion]:
654 # """
655 # Create a new package and multiple package versions in the package dependency graph.
656 #
657 # :param packageName: Name of the new package.
658 # :param versions: List of version string.s
659 # :returns: List of new package version instances.
660 # """
661 # package = Package(packageName, storage=self)
662 # return [PackageVersion(SemanticVersion.Parse(version), package) for version in versions]
664 def SortPackageVersions(self) -> None:
665 """
666 Sort versions within all known packages in reverse order (latest first).
667 """
668 for storage in self._storages.values():
669 storage.SortPackageVersions()
671 def __len__(self) -> int:
672 """
673 Returns the number of known packages.
675 :returns: Number of packages.
676 """
677 return len(self._storages)
679 def __iter__(self) -> Iterator[PackageStorage]:
680 return iter(self._storages.values())
682 def __getitem__(self, name: str) -> PackageStorage:
683 """
684 Access a known package storage in the package dependency graph by storage name.
686 :param name: Name of the package storage.
687 :returns: The package storage.
688 :raises KeyError: If package storage is not known within the package dependency graph.
689 """
690 return self._storages[name]
692 def __str__(self) -> str:
693 """
694 Return a string representation of this graph.
696 :returns: The graph's name and number of known packages.
697 """
698 count = sum(len(storage) for storage in self._storages.values())
699 if count == 0: 699 ↛ 702line 699 didn't jump to line 702 because the condition on line 699 was always true
700 return f"{self._name} (empty)"
701 else:
702 return f"{self._name} ({count})"