# ==================================================================================================================== #
# _____ _ _ ____ _ #
# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ ___ _ __ ___ _ __ __| | ___ _ __ ___ _ _ #
# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | | | |/ _ \ '_ \ / _ \ '_ \ / _` |/ _ \ '_ \ / __| | | | #
# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| |_| | __/ |_) | __/ | | | (_| | __/ | | | (__| |_| | #
# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \___| .__/ \___|_| |_|\__,_|\___|_| |_|\___|\__, | #
# |_| |___/ |___/ |_| |___/ #
# ==================================================================================================================== #
# Authors: #
# Patrick Lehmann #
# #
# License: #
# ==================================================================================================================== #
# Copyright 2025-2026 Patrick Lehmann - Bötzingen, Germany #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); #
# you may not use this file except in compliance with the License. #
# You may obtain a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
# #
# SPDX-License-Identifier: Apache-2.0 #
# ==================================================================================================================== #
#
"""
Implementation of package dependencies.
.. hint::
See :ref:`high-level help <DEPENDENCIES>` for explanations and usage examples.
"""
from datetime import datetime
from typing import Optional as Nullable, Dict, Union, Iterable, Set, Self
try:
from pyTooling.Decorators import export, readonly
from pyTooling.MetaClasses import ExtendedType, abstractmethod, mustoverride
from pyTooling.Exceptions import ToolingException
from pyTooling.Common import getFullyQualifiedName, firstKey
from pyTooling.Versioning import SemanticVersion
except (ImportError, ModuleNotFoundError): # pragma: no cover
print("[pyTooling.Dependency] Could not import from 'pyTooling.*'!")
try:
from Decorators import export, readonly
from MetaClasses import ExtendedType, abstractmethod, mustoverride
from Exceptions import ToolingException
from Common import getFullyQualifiedName, firstKey
from Versioning import SemanticVersion
except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
print("[pyTooling.Dependency] Could not import directly!")
raise ex
[docs]
@export
class PackageVersion(metaclass=ExtendedType, slots=True):
"""
The package's version of a :class:`Package`.
A :class:`Package` has multiple available versions. A version can have multiple dependencies to other
:class:`PackageVersion`s.
"""
_package: "Package" #: Reference to the corresponding package
_version: SemanticVersion #: :class:`SemanticVersion` of this package version.
_releasedAt: Nullable[datetime]
_dependsOn: Dict["Package", Dict[SemanticVersion, "PackageVersion"]] #: Versioned dependencies to other packages.
[docs]
def __init__(self, version: SemanticVersion, package: "Package", releasedAt: Nullable[datetime] = None) -> None:
"""
Initializes a package version.
:param version: Semantic version of this package.
:param package: Package this version is associated to.
:param releasedAt: Optional release date and time.
:raises TypeError: When parameter 'version' is not of type 'SemanticVersion'.
:raises TypeError: When parameter 'package' is not of type 'Package'.
:raises TypeError: When parameter 'releasedAt' is not of type 'datetime'.
:raises ToolingException: When version already exists for the associated package.
"""
if not isinstance(version, SemanticVersion):
ex = TypeError("Parameter 'version' is not of type 'SemanticVersion'.")
ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.")
raise ex
elif version in package._versions:
raise ToolingException(f"Version '{version}' is already registered in package '{package._name}'.")
self._version = version
package._versions[version] = self
if not isinstance(package, Package):
ex = TypeError("Parameter 'package' is not of type 'Package'.")
ex.add_note(f"Got type '{getFullyQualifiedName(package)}'.")
raise ex
self._package = package
if releasedAt is not None and not isinstance(releasedAt, datetime):
ex = TypeError("Parameter 'releasedAt' is not of type 'datetime'.")
ex.add_note(f"Got type '{getFullyQualifiedName(releasedAt)}'.")
raise ex
self._releasedAt = releasedAt
self._dependsOn = {}
@readonly
def Package(self) -> "Package":
"""
Read-only property to access the associated package.
:returns: Associated package.
"""
return self._package
@readonly
def Version(self) -> SemanticVersion:
"""
Read-only property to access the semantic version of a package.
:returns: Semantic version of a package.
"""
return self._version
@readonly
def ReleasedAt(self) -> Nullable[datetime]:
"""
Read-only property to access the release date and time.
:returns: Optional release date and time.
"""
return self._releasedAt
@readonly
def DependsOn(self) -> Dict["Package", Dict[SemanticVersion, "PackageVersion"]]:
"""
Read-only property to access the dictionary of dictionaries referencing dependencies.
The outer dictionary key groups dependencies by :class:`Package`. |br|
The inner dictionary key accesses dependencies by :class:`~pyTooling.Versioning.SemanticVersion`.
:returns: Dictionary of dependencies.
"""
return self._dependsOn
[docs]
def AddDependencyToPackageVersion(self, packageVersion: "PackageVersion") -> None:
"""
Add a dependency from current package version to another package version.
:param packageVersion: Dependency to be added.
"""
if (package := packageVersion._package) in self._dependsOn:
pack = self._dependsOn[package]
if (version := packageVersion._version) in pack:
pass
else:
pack[version] = packageVersion
else:
self._dependsOn[package] = {packageVersion._version: packageVersion}
[docs]
def AddDependencyToPackageVersions(self, packageVersions: Iterable["PackageVersion"]) -> None:
"""
Add multiple dependencies from current package version to a list of other package versions.
:param packageVersions: Dependencies to be added.
"""
# TODO: check for iterable
for packageVersion in packageVersions:
if (package := packageVersion._package) in self._dependsOn:
pack = self._dependsOn[package]
if (version := packageVersion._version) in pack:
pass
else:
pack[version] = packageVersion
else:
self._dependsOn[package] = {packageVersion._version: packageVersion}
[docs]
def AddDependencyTo(
self,
package: Union[str, Package],
version: Union[str, SemanticVersion, Iterable[Union[str, SemanticVersion]]]
) -> None:
"""
Add a dependency from current package version to another package version.
:param package: :class:`Package` object or name of the package.
:param version: :class:`~pyTooling.Versioning.SemanticVersion` object or version string or an iterable thereof.
:return:
"""
if isinstance(package, str):
package = self._package._storage._packages[package]
elif not isinstance(package, Package):
ex = TypeError(f"Parameter 'package' is not of type 'str' nor 'Package'.")
ex.add_note(f"Got type '{getFullyQualifiedName(package)}'.")
raise ex
if isinstance(version, str):
version = SemanticVersion.Parse(version)
elif isinstance(version, Iterable):
for v in version:
if isinstance(v, str):
v = SemanticVersion.Parse(v)
elif not isinstance(v, SemanticVersion):
ex = TypeError(f"Parameter 'version' contains an element, which is not of type 'str' nor 'SemanticVersion'.")
ex.add_note(f"Got type '{getFullyQualifiedName(v)}'.")
raise ex#
packageVersion = package._versions[v]
self.AddDependencyToPackageVersion(packageVersion)
return
elif not isinstance(version, SemanticVersion):
ex = TypeError(f"Parameter 'version' is not of type 'str' nor 'SemanticVersion'.")
ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.")
raise ex
packageVersion = package._versions[version]
self.AddDependencyToPackageVersion(packageVersion)
[docs]
def SortDependencies(self) -> Self:
"""
Sort versions of a package and dependencies by version, thus dependency resolution can work on pre-sorted lists and
dictionaries.
:returns: The instance itself (for method-chaining).
"""
for package, versions in self._dependsOn.items():
self._dependsOn[package] = {version: versions[version] for version in sorted(versions.keys(), reverse=True)}
return self
[docs]
def SolveLatest(self) -> Iterable["PackageVersion"]:
"""
Solve the dependency problem, while using preferably latest versions.
.. todo::
Describe algorithm.
:returns: A list of :class:`PackageVersion`s fulfilling the constraints of the dependency problem.
:raises ToolingException: When there is no valid solution to the problem.
"""
solution: Dict["Package", "PackageVersion"] = {self._package: self}
def _recursion(currentSolution: Dict["Package", "PackageVersion"]) -> bool:
# 1. Identify all required packages based on current selection
requiredPackages: Set["Package"] = set()
for packageVersion in currentSolution.values():
requiredPackages.update(packageVersion._dependsOn.keys())
# 2. Identify which required packages are missing from the solution
missingPackages = requiredPackages - currentSolution.keys()
# Base Case: If no packages are missing, the graph is complete and valid
if len(missingPackages) == 0:
return True
# 3. Pick the next package to resolve
# (Heuristic: we just pick the first one, but could be optimized)
targetPackage = next(iter(missingPackages))
# 4. Determine valid candidates
# The candidate version must satisfy the constraints of all parents currently in the solution
allowedVersions: Nullable[Set[SemanticVersion]] = None
for parentPackageVersion in currentSolution.values():
if targetPackage in parentPackageVersion._dependsOn:
# Get the set of versions allowed by this specific parent
# (Keys of the inner dict are SemanticVersion objects)
parentConstraints = set(parentPackageVersion._dependsOn[targetPackage].keys())
if allowedVersions is None:
allowedVersions = parentConstraints
else:
# Intersect with existing constraints (must satisfy everyone)
allowedVersions &= parentConstraints
# If the intersection is empty, no version satisfies all parents -> backtrack
if not allowedVersions:
return False
# 5. Try candidates (sorted descending to prioritize latest)
# We convert the set to a list and sort it reverse
for version_key in sorted(list(allowedVersions), reverse=True):
candidate = targetPackage._versions[version_key]
# 6. Check compatibility (reverse dependencies)
# Does the candidate depend on anything we have already selected?
# If so, does the candidate accept the version we already picked?
isCompatible = True
for existingPackage, existingPackageVersion in currentSolution.items():
if existingPackage in candidate._dependsOn:
# If candidate relies on 'existingPackage', check if 'existingPackageVersion' is in the allowed list
if existingPackageVersion._version not in candidate._dependsOn[existingPackage]:
isCompatible = False
break
if isCompatible:
# Tentatively add to solution
currentSolution[targetPackage] = candidate
# Recurse
if _recursion(currentSolution):
return True
# If recursion failed, remove (backtrack) and try next version
del currentSolution[targetPackage]
# If we run out of versions for this package, this path is dead
return False
# Run the solver
if _recursion(solution):
return list(solution.values())
else:
raise ToolingException(f"Could not resolve dependencies for '{self}'.")
[docs]
def __len__(self) -> int:
"""
Returns the number of dependencies.
:returns: Number of dependencies.
"""
return len(self._dependsOn)
[docs]
def __str__(self) -> str:
"""
Return a string representation of this package version.
:returns: The package's name and version.
"""
return f"{self._package._name} - {self._version}"
[docs]
@export
class Package(metaclass=ExtendedType, slots=True):
"""
The package, which exists in multiple versions (:class:`PackageVersion`).
"""
_storage: "PackageStorage" #: Reference to the package's storage.
_name: str #: Name of the package.
_versions: Dict[SemanticVersion, PackageVersion] #: A dictionary of available versions for this package.
[docs]
def __init__(self, name: str, *, storage: "PackageStorage") -> None:
"""
Initializes a package.
:param name: Name of the package.
:param storage: The package's storage.
"""
if not isinstance(name, str):
ex = TypeError("Parameter 'name' is not of type 'str'.")
ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
raise ex
self._name = name
if not isinstance(storage, PackageStorage):
ex = TypeError("Parameter 'storage' is not of type 'PackageStorage'.")
ex.add_note(f"Got type '{getFullyQualifiedName(storage)}'.")
raise ex
self._storage = storage
storage._packages[name] = self
self._versions = {}
@readonly
def Storage(self) -> "PackageStorage":
"""
Read-only property to access the package's storage.
:returns: Package storage.
"""
return self._storage
@readonly
def Name(self) -> str:
"""
Read-only property to access the package name.
:returns: Name of the package.
"""
return self._name
@readonly
def Versions(self) -> Dict[SemanticVersion, PackageVersion]:
"""
Read-only property to access the dictionary of available versions.
:returns: Available version dictionary.
"""
return self._versions
[docs]
def SortVersions(self) -> None:
"""
Sort versions within this package in reverse order (latest first).
"""
self._versions = {k: self._versions[k].SortDependencies() for k in sorted(self._versions.keys(), reverse=True)}
[docs]
def __len__(self) -> int:
"""
Returns the number of available versions.
:returns: Number of versions.
"""
return len(self._versions)
[docs]
def __getitem__(self, version: Union[str, SemanticVersion]) -> PackageVersion:
"""
Access a package version in the package by version string or semantic version.
:param version: Version as string or instance.
:returns: The package version.
:raises KeyError: If version is not available for the package.
"""
if isinstance(version, str):
version = SemanticVersion.Parse(version)
elif not isinstance(version, SemanticVersion):
raise ToolingException()
return self._versions[version]
[docs]
def __str__(self) -> str:
"""
Return a string representation of this package.
:returns: The package's name and latest version.
"""
if len(self._versions) == 0:
return f"{self._name} (empty)"
else:
return f"{self._name} (latest: {firstKey(self._versions)})"
[docs]
@export
class PackageStorage(metaclass=ExtendedType, slots=True):
"""
A storage for packages.
"""
_graph: "PackageDependencyGraph" #: Reference to the overall dependency graph data structure.
_name: str #: Package dependency graph name
_packages: Dict[str, Package] #: Dictionary of known packages.
[docs]
def __init__(self, name: str, graph: "PackageDependencyGraph") -> None:
"""
Initializes the package storage.
:param name: Name of the package storage.
:param graph: PackageDependencyGraph instance (parent).
"""
if not isinstance(name, str):
ex = TypeError("Parameter 'name' is not of type 'str'.")
ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
raise ex
self._name = name
if not isinstance(graph, PackageDependencyGraph):
ex = TypeError("Parameter 'graph' is not of type 'PackageDependencyGraph'.")
ex.add_note(f"Got type '{getFullyQualifiedName(graph)}'.")
raise ex
self._graph = graph
graph._storages[name] = self
self._packages = {}
@readonly
def Graph(self) -> "PackageDependencyGraph":
"""
Read-only property to access the package dependency graph.
:returns: Package dependency graph.
"""
return self._graph
@readonly
def Name(self) -> str:
"""
Read-only property to access the package dependency graph's name.
:returns: Name of the package dependency graph.
"""
return self._name
@readonly
def Packages(self) -> Dict[str, Package]:
"""
Read-only property to access the dictionary of known packages.
:returns: Known packages dictionary.
"""
return self._packages
[docs]
def CreatePackage(self, packageName: str) -> Package:
"""
Create a new package in the package dependency graph.
:param packageName: Name of the new package.
:returns: New package's instance.
"""
return Package(packageName, storage=self)
[docs]
def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]:
"""
Create multiple new packages in the package dependency graph.
:param packageNames: List of package names.
:returns: List of new package instances.
"""
return [Package(packageName, storage=self) for packageName in packageNames]
[docs]
def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion:
"""
Create a new package and a package version in the package dependency graph.
:param packageName: Name of the new package.
:param version: Version string.
:returns: New package version instance.
"""
package = Package(packageName, storage=self)
return PackageVersion(SemanticVersion.Parse(version), package)
[docs]
def CreatePackageVersions(self, packageName: str, versions: Iterable[str]) -> Iterable[PackageVersion]:
"""
Create a new package and multiple package versions in the package dependency graph.
:param packageName: Name of the new package.
:param versions: List of version string.s
:returns: List of new package version instances.
"""
package = Package(packageName, storage=self)
return [PackageVersion(SemanticVersion.Parse(version), package) for version in versions]
[docs]
def SortPackageVersions(self) -> None:
"""
Sort versions within all known packages in reverse order (latest first).
"""
for package in self._packages.values():
package.SortVersions()
[docs]
def __len__(self) -> int:
"""
Returns the number of known packages.
:returns: Number of packages.
"""
return len(self._packages)
[docs]
def __getitem__(self, name: str) -> Package:
"""
Access a known package in the package dependency graph by package name.
:param name: Name of the package.
:returns: The package.
:raises KeyError: If package is not known within the package dependency graph.
"""
return self._packages[name]
[docs]
def __str__(self) -> str:
"""
Return a string representation of this graph.
:returns: The graph's name and number of known packages.
"""
if len(self._packages) == 0:
return f"{self._name} (empty)"
else:
return f"{self._name} ({len(self._packages)})"
[docs]
@export
class PackageDependencyGraph(metaclass=ExtendedType, slots=True):
"""
A package dependency graph collecting all known packages.
"""
_name: str #: Package dependency graph name
_storages: Dict[str, PackageStorage] #: Dictionary of known package storages.
[docs]
def __init__(self, name: str) -> None:
"""
Initializes the package dependency graph.
:param name: Name of the dependency graph.
"""
if not isinstance(name, str):
ex = TypeError("Parameter 'name' is not of type 'str'.")
ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
raise ex
self._name = name
self._storages = {}
@readonly
def Name(self) -> str:
"""
Read-only property to access the package dependency graph's name.
:returns: Name of the package dependency graph.
"""
return self._name
@readonly
def Storages(self) -> Dict[str, PackageStorage]:
"""
Read-only property to access the dictionary of known package storages.
:returns: Known package storage dictionary.
"""
return self._storages
# def CreatePackage(self, packageName: str) -> Package:
# """
# Create a new package in the package dependency graph.
#
# :param packageName: Name of the new package.
# :returns: New package's instance.
# """
# return Package(packageName, storage=self)
#
# def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]:
# """
# Create multiple new packages in the package dependency graph.
#
# :param packageNames: List of package names.
# :returns: List of new package instances.
# """
# return [Package(packageName, storage=self) for packageName in packageNames]
#
# def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion:
# """
# Create a new package and a package version in the package dependency graph.
#
# :param packageName: Name of the new package.
# :param version: Version string.
# :returns: New package version instance.
# """
# package = Package(packageName, storage=self)
# return PackageVersion(SemanticVersion.Parse(version), package)
#
# def CreatePackageVersions(self, packageName: str, versions: Iterable[str]) -> Iterable[PackageVersion]:
# """
# Create a new package and multiple package versions in the package dependency graph.
#
# :param packageName: Name of the new package.
# :param versions: List of version string.s
# :returns: List of new package version instances.
# """
# package = Package(packageName, storage=self)
# return [PackageVersion(SemanticVersion.Parse(version), package) for version in versions]
[docs]
def SortPackageVersions(self) -> None:
"""
Sort versions within all known packages in reverse order (latest first).
"""
for storage in self._storages.values():
storage.SortPackageVersions()
[docs]
def __len__(self) -> int:
"""
Returns the number of known packages.
:returns: Number of packages.
"""
return len(self._storages)
[docs]
def __getitem__(self, name: str) -> PackageStorage:
"""
Access a known package storage in the package dependency graph by storage name.
:param name: Name of the package storage.
:returns: The package storage.
:raises KeyError: If package storage is not known within the package dependency graph.
"""
return self._storages[name]
[docs]
def __str__(self) -> str:
"""
Return a string representation of this graph.
:returns: The graph's name and number of known packages.
"""
count = sum(len(storage) for storage in self._storages.values())
if count == 0:
return f"{self._name} (empty)"
else:
return f"{self._name} ({count})"