Coverage for pyTooling/Dependency/__init__.py: 81%
261 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 07:24 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 07:24 +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
46from pyTooling.Warning import Warning
49@export
50class DependencyException(ToolingException):
51 """Base-exception of all exceptions raised by :mod:`pyTooling.Dependency`."""
54@export
55class NoSessionAvailableException(DependencyException):
56 """
57 The operation needs a session to the package index, but no session was opened.
59 A session is created by the package index and handed to the objects it creates.
60 """
63@export
64class ProjectNotFoundException(DependencyException):
65 """The package index doesn't know a project of that name."""
68@export
69class ReleaseNotFoundException(DependencyException):
70 """The project exists in the package index, but not in the requested version."""
73@export
74class BrokenRequirementWarning(Warning):
75 """
76 A requirement carries an environment marker that matches none of the extras declared by the project.
78 Such a requirement can't be assigned to an extra, so it's not reachable through :attr:`Requirements`.
79 """
82@export
83class ReleaseDetailsWarning(Warning):
84 """Downloading the details of a release failed, therefore the release was dropped from the project."""
87@export
88class PackageVersion(metaclass=ExtendedType, slots=True):
89 """
90 The package's version of a :class:`Package`.
92 A :class:`Package` has multiple available versions. A version can have multiple dependencies to other
93 :class:`PackageVersion`s.
94 """
96 _package: "Package" #: Reference to the corresponding package
97 _version: SemanticVersion #: :class:`SemanticVersion` of this package version.
98 _releasedAt: Nullable[datetime]
100 _dependsOn: Dict["Package", Dict[SemanticVersion, "PackageVersion"]] #: Versioned dependencies to other packages.
102 def __init__(self, version: SemanticVersion, package: "Package", releasedAt: Nullable[datetime] = None) -> None:
103 """
104 Initializes a package version.
106 :param version: Semantic version of this package.
107 :param package: Package this version is associated to.
108 :param releasedAt: Optional release date and time.
109 :raises TypeError: When parameter 'version' is not of type 'SemanticVersion'.
110 :raises TypeError: When parameter 'package' is not of type 'Package'.
111 :raises TypeError: When parameter 'releasedAt' is not of type 'datetime'.
112 :raises ToolingException: When version already exists for the associated package.
113 """
114 if not isinstance(version, SemanticVersion): 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true
115 ex = TypeError("Parameter 'version' is not of type 'SemanticVersion'.")
116 ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.")
117 raise ex
118 elif version in package._versions: 118 ↛ 119line 118 didn't jump to line 119 because the condition on line 118 was never true
119 raise ToolingException(f"Version '{version}' is already registered in package '{package._name}'.")
121 self._version = version
122 package._versions[version] = self
124 if not isinstance(package, Package): 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true
125 ex = TypeError("Parameter 'package' is not of type 'Package'.")
126 ex.add_note(f"Got type '{getFullyQualifiedName(package)}'.")
127 raise ex
129 self._package = package
131 if releasedAt is not None and not isinstance(releasedAt, datetime): 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 ex = TypeError("Parameter 'releasedAt' is not of type 'datetime'.")
133 ex.add_note(f"Got type '{getFullyQualifiedName(releasedAt)}'.")
134 raise ex
136 self._releasedAt = releasedAt
138 self._dependsOn = {}
140 @readonly
141 def Package(self) -> "Package":
142 """
143 Read-only property to access the associated package.
145 :returns: Associated package.
146 """
147 return self._package
149 @readonly
150 def Version(self) -> SemanticVersion:
151 """
152 Read-only property to access the semantic version of a package.
154 :returns: Semantic version of a package.
155 """
156 return self._version
158 @readonly
159 def ReleasedAt(self) -> Nullable[datetime]:
160 """
161 Read-only property to access the release date and time.
163 :returns: Optional release date and time.
164 """
165 return self._releasedAt
167 @readonly
168 def DependsOn(self) -> Dict["Package", Dict[SemanticVersion, "PackageVersion"]]:
169 """
170 Read-only property to access the dictionary of dictionaries referencing dependencies.
172 The outer dictionary key groups dependencies by :class:`Package`. |br|
173 The inner dictionary key accesses dependencies by :class:`~pyTooling.Versioning.SemanticVersion`.
175 :returns: Dictionary of dependencies.
176 """
177 return self._dependsOn
179 def AddDependencyToPackageVersion(self, packageVersion: "PackageVersion") -> None:
180 """
181 Add a dependency from current package version to another package version.
183 :param packageVersion: Dependency to be added.
184 """
185 if (package := packageVersion._package) in self._dependsOn:
186 pack = self._dependsOn[package]
187 if (version := packageVersion._version) in pack: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 pass
189 else:
190 pack[version] = packageVersion
191 else:
192 self._dependsOn[package] = {packageVersion._version: packageVersion}
194 def AddDependencyToPackageVersions(self, packageVersions: Iterable["PackageVersion"]) -> None:
195 """
196 Add multiple dependencies from current package version to a list of other package versions.
198 :param packageVersions: Dependencies to be added.
199 """
200 # TODO: check for iterable
202 for packageVersion in packageVersions:
203 if (package := packageVersion._package) in self._dependsOn:
204 pack = self._dependsOn[package]
205 if (version := packageVersion._version) in pack: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true
206 pass
207 else:
208 pack[version] = packageVersion
209 else:
210 self._dependsOn[package] = {packageVersion._version: packageVersion}
212 def AddDependencyTo(
213 self,
214 package: "str | Package",
215 version: str | SemanticVersion | Iterable[str | SemanticVersion]
216 ) -> None:
217 """
218 Add a dependency from current package version to another package version.
220 :param package: :class:`Package` object or name of the package.
221 :param version: :class:`~pyTooling.Versioning.SemanticVersion` object or version string or an iterable thereof.
222 """
223 if isinstance(package, str):
224 package = self._package._storage._packages[package]
225 elif not isinstance(package, Package): 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true
226 ex = TypeError(f"Parameter 'package' is not of type 'str' nor 'Package'.")
227 ex.add_note(f"Got type '{getFullyQualifiedName(package)}'.")
228 raise ex
230 if isinstance(version, str):
231 version = SemanticVersion.Parse(version)
232 elif isinstance(version, Iterable):
233 for v in version:
234 if isinstance(v, str): 234 ↛ 236line 234 didn't jump to line 236 because the condition on line 234 was always true
235 v = SemanticVersion.Parse(v)
236 elif not isinstance(v, SemanticVersion):
237 ex = TypeError(f"Parameter 'version' contains an element, which is not of type 'str' nor 'SemanticVersion'.")
238 ex.add_note(f"Got type '{getFullyQualifiedName(v)}'.")
239 raise ex#
241 packageVersion = package._versions[v]
242 self.AddDependencyToPackageVersion(packageVersion)
244 return
245 elif not isinstance(version, SemanticVersion): 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 ex = TypeError(f"Parameter 'version' is not of type 'str' nor 'SemanticVersion'.")
247 ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.")
248 raise ex
250 packageVersion = package._versions[version]
251 self.AddDependencyToPackageVersion(packageVersion)
253 def SortDependencies(self) -> Self:
254 """
255 Sort versions of a package and dependencies by version, thus dependency resolution can work on pre-sorted lists and
256 dictionaries.
258 :returns: The instance itself (for method-chaining).
259 """
260 for package, versions in self._dependsOn.items():
261 self._dependsOn[package] = {version: versions[version] for version in sorted(versions.keys(), reverse=True)}
262 return self
264 def SolveLatest(self) -> Iterable["PackageVersion"]:
265 """
266 Solve the dependency problem, while using preferably latest versions.
268 .. todo::
270 Describe algorithm.
272 :returns: A list of :class:`PackageVersion`s fulfilling the constraints of the dependency problem.
273 :raises ToolingException: When there is no valid solution to the problem.
274 """
275 solution: Dict["Package", "PackageVersion"] = {self._package: self}
277 def _recursion(currentSolution: Dict["Package", "PackageVersion"]) -> bool:
278 # 1. Identify all required packages based on current selection
279 requiredPackages: Set["Package"] = set()
280 for packageVersion in currentSolution.values():
281 requiredPackages.update(packageVersion.DependsOn.keys())
283 # 2. Identify which required packages are missing from the solution
284 missingPackages = requiredPackages - currentSolution.keys()
286 # Base Case: If no packages are missing, the graph is complete and valid
287 if len(missingPackages) == 0:
288 return True
290 # 3. Pick the next package to resolve
291 # (Heuristic: we just pick the first one, but could be optimized)
292 targetPackage = next(iter(missingPackages))
294 # 4. Determine valid candidates
295 # The candidate version must satisfy the constraints of all parents currently in the solution
296 allowedVersions: Nullable[Set[SemanticVersion]] = None
298 for parentPackageVersion in currentSolution.values():
299 if targetPackage in parentPackageVersion.DependsOn:
300 # Get the set of versions allowed by this specific parent
301 # (Keys of the inner dict are SemanticVersion objects)
302 parentConstraints = set(parentPackageVersion.DependsOn[targetPackage].keys())
304 if allowedVersions is None:
305 allowedVersions = parentConstraints
306 else:
307 # Intersect with existing constraints (must satisfy everyone)
308 allowedVersions &= parentConstraints
310 # If the intersection is empty, no version satisfies all parents -> backtrack
311 if not allowedVersions:
312 return False
314 # 5. Try candidates (sorted descending to prioritize latest)
315 # We convert the set to a list and sort it reverse
316 for version_key in sorted(list(allowedVersions), reverse=True):
317 candidate = targetPackage.Versions[version_key]
319 # 6. Check compatibility (reverse dependencies)
320 # Does the candidate depend on anything we have already selected?
321 # If so, does the candidate accept the version we already picked?
322 isCompatible = True
323 for existingPackage, existingPackageVersion in currentSolution.items():
324 if existingPackage in candidate.DependsOn:
325 # If candidate relies on 'existingPackage', check if 'existingPackageVersion' is in the allowed list
326 if existingPackageVersion._version not in candidate.DependsOn[existingPackage]:
327 isCompatible = False
328 break
330 if isCompatible:
331 # Tentatively add to solution
332 currentSolution[targetPackage] = candidate
334 # Recurse
335 if _recursion(currentSolution):
336 return True
338 # If recursion failed, remove (backtrack) and try next version
339 del currentSolution[targetPackage]
341 # If we run out of versions for this package, this path is dead
342 return False
344 # Run the solver
345 if _recursion(solution):
346 return list(solution.values())
347 else:
348 raise ToolingException(f"Could not resolve dependencies for '{self}'.")
350 def __len__(self) -> int:
351 """
352 Returns the number of dependencies.
354 :returns: Number of dependencies.
355 """
356 return len(self._dependsOn)
358 def __str__(self) -> str:
359 """
360 Return a string representation of this package version.
362 :returns: The package's name and version.
363 """
364 return f"{self._package._name} - {self._version}"
367@export
368class Package(metaclass=ExtendedType, slots=True):
369 """
370 The package, which exists in multiple versions (:class:`PackageVersion`).
371 """
372 _storage: "PackageStorage" #: Reference to the package's storage.
373 _name: str #: Name of the package.
375 _versions: Dict[SemanticVersion, PackageVersion] #: A dictionary of available versions for this package.
377 def __init__(self, name: str, *, storage: "PackageStorage") -> None:
378 """
379 Initializes a package.
381 :param name: Name of the package.
382 :param storage: The package's storage.
383 """
384 if not isinstance(name, str): 384 ↛ 385line 384 didn't jump to line 385 because the condition on line 384 was never true
385 ex = TypeError("Parameter 'name' is not of type 'str'.")
386 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
387 raise ex
389 self._name = name
391 if not isinstance(storage, PackageStorage): 391 ↛ 392line 391 didn't jump to line 392 because the condition on line 391 was never true
392 ex = TypeError("Parameter 'storage' is not of type 'PackageStorage'.")
393 ex.add_note(f"Got type '{getFullyQualifiedName(storage)}'.")
394 raise ex
396 self._storage = storage
397 storage._packages[name] = self
399 self._versions = {}
401 @readonly
402 def Storage(self) -> "PackageStorage":
403 """
404 Read-only property to access the package's storage.
406 :returns: Package storage.
407 """
408 return self._storage
410 @readonly
411 def Name(self) -> str:
412 """
413 Read-only property to access the package name.
415 :returns: Name of the package.
416 """
417 return self._name
419 @readonly
420 def Versions(self) -> Dict[SemanticVersion, PackageVersion]:
421 """
422 Read-only property to access the dictionary of available versions.
424 :returns: Available version dictionary.
425 """
426 return self._versions
428 @readonly
429 def VersionCount(self) -> int:
430 """
431 Read-only property to return the number of versions this package has.
433 :returns: Number of versions.
434 """
435 return len(self._versions)
437 def SortVersions(self) -> None:
438 """
439 Sort versions within this package in reverse order (latest first).
440 """
441 self._versions = {k: self._versions[k].SortDependencies() for k in sorted(self._versions.keys(), reverse=True)}
443 def __len__(self) -> int:
444 """
445 Returns the number of available versions.
447 :returns: Number of versions.
448 """
449 return len(self._versions)
451 def __iter__(self) -> Iterator[PackageVersion]:
452 return iter(self._versions.values())
454 def __getitem__(self, version: str | SemanticVersion) -> PackageVersion:
455 """
456 Access a package version in the package by version string or semantic version.
458 :param version: Version as string or instance.
459 :returns: The package version.
460 :raises KeyError: If version is not available for the package.
461 """
462 if isinstance(version, str): 462 ↛ 464line 462 didn't jump to line 464 because the condition on line 462 was always true
463 version = SemanticVersion.Parse(version)
464 elif not isinstance(version, SemanticVersion):
465 # TODO: raise proper type error
466 raise TypeError()
468 return self._versions[version]
470 def __str__(self) -> str:
471 """
472 Return a string representation of this package.
474 :returns: The package's name and latest version.
475 """
476 if len(self._versions) == 0:
477 return f"{self._name} (empty)"
478 else:
479 return f"{self._name} (latest: {firstKey(self._versions)})"
482@export
483class PackageStorage(metaclass=ExtendedType, slots=True):
484 """
485 A storage for packages.
486 """
487 _graph: "PackageDependencyGraph" #: Reference to the overall dependency graph data structure.
488 _name: str #: Package dependency graph name
489 _packages: Dict[str, Package] #: Dictionary of known packages.
491 def __init__(self, name: str, graph: "PackageDependencyGraph") -> None:
492 """
493 Initializes the package storage.
495 :param name: Name of the package storage.
496 :param graph: PackageDependencyGraph instance (parent).
497 """
498 if not isinstance(name, str): 498 ↛ 499line 498 didn't jump to line 499 because the condition on line 498 was never true
499 ex = TypeError("Parameter 'name' is not of type 'str'.")
500 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
501 raise ex
503 self._name = name
505 if not isinstance(graph, PackageDependencyGraph): 505 ↛ 506line 505 didn't jump to line 506 because the condition on line 505 was never true
506 ex = TypeError("Parameter 'graph' is not of type 'PackageDependencyGraph'.")
507 ex.add_note(f"Got type '{getFullyQualifiedName(graph)}'.")
508 raise ex
510 self._graph = graph
511 graph._storages[name] = self
513 self._packages = {}
515 @readonly
516 def Graph(self) -> "PackageDependencyGraph":
517 """
518 Read-only property to access the package dependency graph.
520 :returns: Package dependency graph.
521 """
522 return self._graph
524 @readonly
525 def Name(self) -> str:
526 """
527 Read-only property to access the package dependency graph's name.
529 :returns: Name of the package dependency graph.
530 """
531 return self._name
533 @readonly
534 def Packages(self) -> Dict[str, Package]:
535 """
536 Read-only property to access the dictionary of known packages.
538 :returns: Known packages dictionary.
539 """
540 return self._packages
542 @readonly
543 def PackageCount(self) -> int:
544 """
545 Read-only property to return the number of packages in this storage.
547 :returns: Number of packages.
548 """
549 return len(self._packages)
551 def CreatePackage(self, packageName: str) -> Package:
552 """
553 Create a new package in the package dependency graph.
555 :param packageName: Name of the new package.
556 :returns: New package's instance.
557 """
558 return Package(packageName, storage=self)
560 def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]:
561 """
562 Create multiple new packages in the package dependency graph.
564 :param packageNames: List of package names.
565 :returns: List of new package instances.
566 """
567 return [Package(packageName, storage=self) for packageName in packageNames]
569 def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion:
570 """
571 Create a new package and a package version in the package dependency graph.
573 :param packageName: Name of the new package.
574 :param version: Version string.
575 :returns: New package version instance.
576 """
577 package = Package(packageName, storage=self)
578 return PackageVersion(SemanticVersion.Parse(version), package)
580 def CreatePackageVersions(self, packageName: str, versions: Iterable[str]) -> Iterable[PackageVersion]:
581 """
582 Create a new package and multiple package versions in the package dependency graph.
584 :param packageName: Name of the new package.
585 :param versions: List of version string.s
586 :returns: List of new package version instances.
587 """
588 package = Package(packageName, storage=self)
589 return [PackageVersion(SemanticVersion.Parse(version), package) for version in versions]
591 def SortPackageVersions(self) -> None:
592 """
593 Sort versions within all known packages in reverse order (latest first).
594 """
595 for package in self._packages.values():
596 package.SortVersions()
598 def __len__(self) -> int:
599 """
600 Returns the number of known packages.
602 :returns: Number of packages.
603 """
604 return len(self._packages)
606 def __iter__(self) -> Iterator[Package]:
607 return iter(self._packages.values())
609 def __getitem__(self, name: str) -> Package:
610 """
611 Access a known package in the package dependency graph by package name.
613 :param name: Name of the package.
614 :returns: The package.
615 :raises KeyError: If package is not known within the package dependency graph.
616 """
617 return self._packages[name]
619 def __str__(self) -> str:
620 """
621 Return a string representation of this graph.
623 :returns: The graph's name and number of known packages.
624 """
625 if len(self._packages) == 0: 625 ↛ 628line 625 didn't jump to line 628 because the condition on line 625 was always true
626 return f"{self._name} (empty)"
627 else:
628 return f"{self._name} ({len(self._packages)})"
631@export
632class PackageDependencyGraph(metaclass=ExtendedType, slots=True):
633 """
634 A package dependency graph collecting all known packages.
635 """
636 _name: str #: Package dependency graph name
637 _storages: Dict[str, PackageStorage] #: Dictionary of known package storages.
639 def __init__(self, name: str) -> None:
640 """
641 Initializes the package dependency graph.
643 :param name: Name of the dependency graph.
644 """
645 if not isinstance(name, str): 645 ↛ 646line 645 didn't jump to line 646 because the condition on line 645 was never true
646 ex = TypeError("Parameter 'name' is not of type 'str'.")
647 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
648 raise ex
650 self._name = name
652 self._storages = {}
654 @readonly
655 def Name(self) -> str:
656 """
657 Read-only property to access the package dependency graph's name.
659 :returns: Name of the package dependency graph.
660 """
661 return self._name
663 @readonly
664 def Storages(self) -> Dict[str, PackageStorage]:
665 """
666 Read-only property to access the dictionary of known package storages.
668 :returns: Known package storage dictionary.
669 """
670 return self._storages
672 # def CreatePackage(self, packageName: str) -> Package:
673 # """
674 # Create a new package in the package dependency graph.
675 #
676 # :param packageName: Name of the new package.
677 # :returns: New package's instance.
678 # """
679 # return Package(packageName, storage=self)
680 #
681 # def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]:
682 # """
683 # Create multiple new packages in the package dependency graph.
684 #
685 # :param packageNames: List of package names.
686 # :returns: List of new package instances.
687 # """
688 # return [Package(packageName, storage=self) for packageName in packageNames]
689 #
690 # def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion:
691 # """
692 # Create a new package and a package version in the package dependency graph.
693 #
694 # :param packageName: Name of the new package.
695 # :param version: Version string.
696 # :returns: New package version instance.
697 # """
698 # package = Package(packageName, storage=self)
699 # return PackageVersion(SemanticVersion.Parse(version), package)
700 #
701 # def CreatePackageVersions(self, packageName: str, versions: Iterable[str]) -> Iterable[PackageVersion]:
702 # """
703 # Create a new package and multiple package versions in the package dependency graph.
704 #
705 # :param packageName: Name of the new package.
706 # :param versions: List of version string.s
707 # :returns: List of new package version instances.
708 # """
709 # package = Package(packageName, storage=self)
710 # return [PackageVersion(SemanticVersion.Parse(version), package) for version in versions]
712 def SortPackageVersions(self) -> None:
713 """
714 Sort versions within all known packages in reverse order (latest first).
715 """
716 for storage in self._storages.values():
717 storage.SortPackageVersions()
719 def __len__(self) -> int:
720 """
721 Returns the number of known packages.
723 :returns: Number of packages.
724 """
725 return len(self._storages)
727 def __iter__(self) -> Iterator[PackageStorage]:
728 return iter(self._storages.values())
730 def __getitem__(self, name: str) -> PackageStorage:
731 """
732 Access a known package storage in the package dependency graph by storage name.
734 :param name: Name of the package storage.
735 :returns: The package storage.
736 :raises KeyError: If package storage is not known within the package dependency graph.
737 """
738 return self._storages[name]
740 def __str__(self) -> str:
741 """
742 Return a string representation of this graph.
744 :returns: The graph's name and number of known packages.
745 """
746 count = sum(len(storage) for storage in self._storages.values())
747 if count == 0: 747 ↛ 750line 747 didn't jump to line 750 because the condition on line 747 was always true
748 return f"{self._name} (empty)"
749 else:
750 return f"{self._name} ({count})"