Coverage for pyTooling/Dependency/Resolver.py: 90%
441 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ ___ _ __ ___ _ __ __| | ___ _ __ ___ _ _ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | | | |/ _ \ '_ \ / _ \ '_ \ / _` |/ _ \ '_ \ / __| | | | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| |_| | __/ |_) | __/ | | | (_| | __/ | | | (__| |_| | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \___| .__/ \___|_| |_|\__,_|\___|_| |_|\___|\__, | #
7# |_| |___/ |___/ |_| |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-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"""
31Resolving requirements into releases of a Python package index.
33A :class:`Resolver` selects the newest release matching each requirement, downloads its details and follows its
34requirements, as far as its limit allows. The result is a :class:`Resolution`: a tree of :class:`ResolvedRequirement`
35objects, each carrying the selected release or the reason there is none. The selected releases are connected in the
36index's dependency graph, too.
38Importing this module needs the ``dependencies`` extra (:mod:`aiohttp`, :mod:`packaging`), like
39:mod:`pyTooling.Dependency.Python`.
41:raises MissingDependencyError: If the 'dependencies' extra isn't installed.
42"""
43from __future__ import annotations
45from asyncio import run as asyncio_run, gather as asyncio_gather
46from enum import Enum
47from typing import Any, Callable, Iterable, Iterator, Mapping, Optional as Nullable
49from pyTooling.Exceptions import MissingDependencyError
51try:
52 from aiohttp import ClientResponseError, ClientSession, ClientTimeout
53except ImportError as ex: # pragma: no cover
54 raise MissingDependencyError(dependency="aiohttp", extra="dependencies") from ex
56try:
57 from packaging.markers import default_environment
58 from packaging.requirements import Requirement
59 from packaging.specifiers import SpecifierSet
60 from packaging.utils import canonicalize_name
61except ImportError as ex: # pragma: no cover
62 raise MissingDependencyError(dependency="packaging", extra="dependencies") from ex
64from pyTooling.Decorators import export, readonly
65from pyTooling.MetaClasses import ExtendedType
66from pyTooling.Common import StringEnum, getFullyQualifiedName
67from pyTooling.Timeout import Timeout
68from pyTooling.Warning import WarningCollector
69from pyTooling.Dependency import ExtraNotFoundError, PackageIndexFailureWarning, UndeclaredExtraWarning, \
70 ReleaseNotFoundError, ResolutionTimeoutWarning, UnresolvedRequirementWarning
71from pyTooling.Dependency.Python import LazyLoaderState, Project, PythonPackageIndex, Release, RequirementsFile
73_Requirement = Requirement
74_Project = Project
75_Release = Release
78@export
79class ResolutionStrategy(StringEnum):
80 """When a :class:`Resolver` resolves the requirements of a selected release."""
82 Lazy = "lazy" #: A release's requirements are resolved when its dependencies are first asked for.
83 Eager = "eager" #: Every requirement up to the limit is resolved up front, one level at a time.
85 DEFAULT = Lazy #: The strategy used if none is given.
88@export
89class PreReleasePolicy(StringEnum):
90 """
91 Whether a :class:`Resolver` selects pre-releases.
93 A requirement naming a pre-release (``pyTooling >= 10.0.0rc3``) admits pre-releases with either policy, as does a
94 requirement only pre-releases match (:pep:`440`, as :mod:`packaging` and pip apply it).
95 """
97 Exclude = "exclude" #: Pre-releases only if the requirement names one, or if nothing else matches.
98 Include = "include" #: Pre-releases are selected like final releases.
100 DEFAULT = Exclude #: The policy used if none is given.
103@export
104class ResolutionStatus(Enum):
105 """What resolving a requirement came to."""
107 Resolved = 1 #: A release matching the requirement was selected, and its details were downloaded.
108 MarkerNotApplying = 2 #: The requirement's environment marker doesn't apply to the resolver's environment.
109 UnknownProject = 3 #: The package index doesn't know the required project.
110 NoMatchingRelease = 4 #: No release of the project matches the requirement's specifier.
111 UndescribedRelease = 5 #: The package index can't describe the selected release.
112 Cycle = 6 #: The project is required further up the path already, so it isn't followed again.
113 LimitReached = 7 #: The requirement is below the resolver's limit, so it wasn't resolved.
114 IndexFailure = 8 #: The package index failed to answer for the project or the selected release.
115 TimeoutExpired = 9 #: The current timeout expired before the requirement was resolved.
118@export
119class ResolvedRequirement(metaclass=ExtendedType, slots=True):
120 """
121 A requirement as a :class:`Resolver` resolved it: the selected release, or the reason there is none.
123 A resolved requirement's dependencies are the requirements of its release, resolved again. With the strategy
124 :attr:`~ResolutionStrategy.Lazy`, they are resolved when :attr:`Dependencies` is first asked for.
125 """
127 _resolution: Resolution #: The resolution this requirement belongs to.
128 _parent: Nullable[ResolvedRequirement] #: The requirement whose release requires this one.
129 _requirement: _Requirement #: The requirement as stated.
130 _extra: Nullable[str] #: The extra the requirement belongs to, if any.
131 _level: int #: Number of requirements above this one.
132 _status: Nullable[ResolutionStatus] #: What resolving the requirement came to.
133 _project: Nullable[_Project] #: The required project, if the index knows it.
134 _release: Nullable[_Release] #: The selected release, if one matches.
135 _dependencies: Nullable[list[ResolvedRequirement]] #: The release's requirements, once resolved.
137 def __init__(
138 self,
139 requirement: Requirement,
140 resolution: Resolution,
141 *,
142 parent: Nullable[ResolvedRequirement] = None,
143 extra: Nullable[str] = None
144 ) -> None:
145 """
146 Initializes a requirement to resolve.
148 :param requirement: The requirement as stated.
149 :param resolution: The resolution this requirement belongs to.
150 :param parent: Optional, the requirement whose release requires this one. Default: ``None``, a requirement
151 given to the resolver.
152 :param extra: Optional, the extra the requirement belongs to. Default: ``None``.
153 :raises ValueError: If parameter 'requirement' is ``None``.
154 :raises TypeError: If parameter 'requirement' is not of type :class:`~packaging.requirements.Requirement`.
155 :raises ValueError: If parameter 'resolution' is ``None``.
156 :raises TypeError: If parameter 'resolution' is not of type :class:`Resolution`.
157 :raises TypeError: If parameter 'parent' is not of type :class:`ResolvedRequirement`.
158 :raises TypeError: If parameter 'extra' is not of type :class:`str`.
159 """
160 if requirement is None: 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true
161 raise ValueError("Parameter 'requirement' is None.")
162 elif not isinstance(requirement, Requirement): 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true
163 ex = TypeError("Parameter 'requirement' is not of type 'Requirement'.")
164 ex.add_note(f"Got type '{getFullyQualifiedName(requirement)}'.")
165 raise ex
167 if resolution is None: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 raise ValueError("Parameter 'resolution' is None.")
169 elif not isinstance(resolution, Resolution): 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
170 ex = TypeError("Parameter 'resolution' is not of type 'Resolution'.")
171 ex.add_note(f"Got type '{getFullyQualifiedName(resolution)}'.")
172 raise ex
174 if parent is not None and not isinstance(parent, ResolvedRequirement): 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true
175 ex = TypeError("Parameter 'parent' is not of type 'ResolvedRequirement'.")
176 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
177 raise ex
179 if extra is not None and not isinstance(extra, str): 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 ex = TypeError("Parameter 'extra' is not of type 'str'.")
181 ex.add_note(f"Got type '{getFullyQualifiedName(extra)}'.")
182 raise ex
184 self._resolution = resolution
185 self._parent = parent
186 self._requirement = requirement
187 self._extra = extra
188 self._level = 0 if parent is None else parent._level + 1
189 self._status = None
190 self._project = None
191 self._release = None
192 self._dependencies = None
194 @readonly
195 def Requirement(self) -> Requirement:
196 """
197 Read-only property to access the requirement as stated (:attr:`_requirement`).
199 :returns: The requirement.
200 """
201 return self._requirement
203 @readonly
204 def Parent(self) -> Nullable[ResolvedRequirement]:
205 """
206 Read-only property to access the requirement whose release requires this one (:attr:`_parent`).
208 :returns: The requiring requirement, or ``None`` for a requirement given to the resolver.
209 """
210 return self._parent
212 @readonly
213 def Extra(self) -> Nullable[str]:
214 """
215 Read-only property to access the extra the requirement belongs to (:attr:`_extra`).
217 :returns: The extra's name, or ``None`` for an unconditional requirement.
218 """
219 return self._extra
221 @readonly
222 def Level(self) -> int:
223 """
224 Read-only property to access the number of requirements above this one (:attr:`_level`).
226 :returns: ``0`` for a requirement given to the resolver, ``1`` for a requirement of its release, and so on.
227 """
228 return self._level
230 @readonly
231 def Status(self) -> Nullable[ResolutionStatus]:
232 """
233 Read-only property to access what resolving the requirement came to (:attr:`_status`).
235 :returns: The status, or ``None`` while the requirement isn't resolved yet.
236 """
237 return self._status
239 @readonly
240 def Project(self) -> Nullable[Project]:
241 """
242 Read-only property to access the required project (:attr:`_project`).
244 :returns: The project, or ``None`` if the index doesn't know it or it wasn't looked up.
245 """
246 return self._project
248 @readonly
249 def Release(self) -> Nullable[Release]:
250 """
251 Read-only property to access the selected release (:attr:`_release`).
253 For a requirement closing a cycle, it's the release selected further up the path.
255 :returns: The release, or ``None`` if none was selected.
256 """
257 return self._release
259 @readonly
260 def Dependencies(self) -> tuple[ResolvedRequirement, ...]:
261 """
262 Read-only property to return the release's requirements, resolved.
264 With the strategy :attr:`~ResolutionStrategy.Lazy`, the first access resolves them. Below the resolver's limit,
265 they carry the status :attr:`~ResolutionStatus.LimitReached`.
267 :returns: Tuple of resolved requirements; empty if the requirement wasn't resolved to
268 a release, or closes a cycle.
269 :raises UnhandledCriticalWarningError: If the dependencies are resolved now, a critical warning (see
270 :class:`Resolver`) is reported and no
271 :class:`~pyTooling.Warning.WarningCollector` collects it.
272 """
273 if self._dependencies is None:
274 self._resolution._resolver._ResolveDependencies((self, ))
276 return tuple(self._dependencies)
278 def IterateDependencies(
279 self,
280 predicate: Nullable[Callable[[ResolvedRequirement], bool]] = None
281 ) -> Iterator[ResolvedRequirement]:
282 """
283 Iterate the release's requirements, resolved.
285 :param predicate: Optional, a filter function; only requirements it returns ``True`` for are yielded.
286 :returns: An iterator over the resolved requirements.
287 """
288 if predicate is None: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true
289 yield from self.Dependencies
290 else:
291 for dependency in self.Dependencies: 291 ↛ exitline 291 didn't return from function 'IterateDependencies' because the loop on line 291 didn't complete
292 if predicate(dependency):
293 yield dependency
295 def __str__(self) -> str:
296 """
297 Return the requirement and what resolving it came to.
299 :returns: The requirement, followed by the selected release's version or the status.
300 """
301 if self._status is ResolutionStatus.Resolved:
302 return f"{self._requirement} -> {self._release.Version}"
304 status = "not resolved" if self._status is None else self._status.name
305 return f"{self._requirement} ({status})"
308@export
309class Resolution(metaclass=ExtendedType, slots=True):
310 """
311 The result of a :class:`Resolver`: the requirements it was given, resolved, as the roots of a tree.
312 """
314 _resolver: _Resolver #: The resolver that created the resolution.
315 _requirements: list[ResolvedRequirement] #: The requirements given to the resolver, resolved.
317 def __init__(self, resolver: Resolver) -> None:
318 """
319 Initializes an empty resolution.
321 :param resolver: The resolver creating the resolution.
322 :raises ValueError: If parameter 'resolver' is ``None``.
323 :raises TypeError: If parameter 'resolver' is not of type :class:`Resolver`.
324 """
325 if resolver is None: 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true
326 raise ValueError("Parameter 'resolver' is None.")
327 elif not isinstance(resolver, Resolver): 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true
328 ex = TypeError("Parameter 'resolver' is not of type 'Resolver'.")
329 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.")
330 raise ex
332 self._resolver = resolver
333 self._requirements = []
335 @readonly
336 def Resolver(self) -> Resolver:
337 """
338 Read-only property to access the resolver that created the resolution (:attr:`_resolver`).
340 :returns: The resolver.
341 """
342 return self._resolver
344 @readonly
345 def Requirements(self) -> tuple[ResolvedRequirement, ...]:
346 """
347 Read-only property to return the requirements given to the resolver, resolved (:attr:`_requirements`).
349 :returns: Tuple of resolved requirements, in the order they were given.
350 """
351 return tuple(self._requirements)
353 @readonly
354 def RequirementCount(self) -> int:
355 """
356 Read-only property to return the number of requirements given to the resolver.
358 :returns: Number of requirements.
359 """
360 return len(self._requirements)
362 def IterateRequirements(
363 self,
364 predicate: Nullable[Callable[[ResolvedRequirement], bool]] = None
365 ) -> Iterator[ResolvedRequirement]:
366 """
367 Iterate the requirements given to the resolver, resolved.
369 :param predicate: Optional, a filter function; only requirements it returns ``True`` for are yielded.
370 :returns: An iterator over the resolved requirements.
371 """
372 if predicate is None:
373 yield from self._requirements
374 else:
375 for requirement in self._requirements:
376 if predicate(requirement):
377 yield requirement
380@export
381class Resolver(metaclass=ExtendedType, slots=True):
382 """
383 Resolves requirements into releases of a Python package index.
385 For each requirement, the newest release matching its specifier is selected, and its details are downloaded. Whether
386 a pre-release can be selected is the :class:`PreReleasePolicy`; a requirement naming a pre-release selects one with
387 either policy. A requirement naming extras (``pyTooling[yaml]``) brings in those extras' requirements as well, and
388 their own requirements in turn. The release's requirements are resolved the same way, as deep as the limit allows,
389 either up front or when they are first asked for (:class:`ResolutionStrategy`). A project required again further
390 up the path closes a cycle and isn't followed. The downloads of one level run in parallel.
392 Each requirement keeps what resolving it came to (:class:`ResolutionStatus`): a requirement whose project the index
393 doesn't know, or whose selected release it can't describe, is reported as an
394 :exc:`~pyTooling.Dependency.UnresolvedRequirementWarning` to a :class:`~pyTooling.Warning.WarningCollector`, and
395 stays in the resolution. A server error or a failed connection of the index is reported as a critical
396 :exc:`~pyTooling.Dependency.PackageIndexFailureWarning`. The selected releases are connected in the index's
397 dependency graph.
399 .. important::
401 The critical warnings must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the
402 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised and resolving
403 stops: :exc:`~pyTooling.Dependency.PackageIndexFailureWarning`,
404 :exc:`~pyTooling.Dependency.ResolutionTimeoutWarning`, :exc:`~pyTooling.Dependency.UnsupportedVersionWarning`
405 (a release whose version can't be parsed) and :exc:`~pyTooling.Dependency.UndeclaredExtraWarning` (a
406 requirement naming an extra the selected release doesn't declare).
408 .. attention::
410 It is no installer's solver: every requirement is resolved on its own. Two requirements of the same project with
411 conflicting specifiers select two releases; nothing is backtracked.
413 Environment markers are evaluated if an environment is given (a mapping as
414 :func:`packaging.markers.default_environment` returns it); a requirement whose marker doesn't apply gets the status
415 :attr:`~ResolutionStatus.MarkerNotApplying`. Without an environment, markers aren't evaluated and every requirement
416 is resolved. :class:`CurrentEnvironmentResolver` uses the running interpreter's environment.
418 Resolving honours the current :class:`~pyTooling.Timeout.Timeout` of the calling thread: each level checks it, and
419 the downloads of a level get the time left as their timeout. Requirements not resolved when it expires get the
420 status :attr:`~ResolutionStatus.TimeoutExpired`, reported once per level as a critical
421 :exc:`~pyTooling.Dependency.ResolutionTimeoutWarning`; the resolution returned is partial. With the strategy
422 :attr:`~ResolutionStrategy.Lazy`, a level resolved later uses the timeout current then.
423 """
425 _index: PythonPackageIndex #: The package index the requirements are resolved against.
426 _strategy: ResolutionStrategy #: When the requirements of a selected release are resolved.
427 _limit: Nullable[int] #: Number of levels resolved below the given requirements.
428 _preReleases: PreReleasePolicy #: Whether pre-releases are selected.
429 _environment: Nullable[dict[str, str]] #: Environment markers are evaluated against, if any.
430 _undescribed: set[Release] #: Releases the index couldn't describe.
431 _failed: set[Project | Release] #: Projects and releases the index failed to answer for.
433 def __init__(
434 self,
435 index: PythonPackageIndex,
436 *,
437 strategy: ResolutionStrategy = ResolutionStrategy.DEFAULT,
438 limit: Nullable[int] = None,
439 preReleases: PreReleasePolicy = PreReleasePolicy.DEFAULT,
440 environment: Nullable[Mapping[str, str]] = None
441 ) -> None:
442 """
443 Initializes a resolver.
445 :param index: The package index the requirements are resolved against.
446 :param strategy: Optional, when the requirements of a selected release are resolved. Default:
447 :attr:`~ResolutionStrategy.Lazy`.
448 :param limit: Optional, number of levels resolved below the given requirements; ``0`` resolves only the
449 given requirements. Default: ``None``, unlimited.
450 :param preReleases: Optional, whether pre-releases are selected. Default: :attr:`~PreReleasePolicy.Exclude`.
451 :param environment: Optional, the environment markers are evaluated against. Default: ``None``, markers aren't
452 evaluated.
453 :raises ValueError: If parameter 'index' is ``None``.
454 :raises TypeError: If parameter 'index' is not of type :class:`~pyTooling.Dependency.Python.PythonPackageIndex`.
455 :raises ValueError: If parameter 'strategy' is ``None``.
456 :raises TypeError: If parameter 'strategy' is not of type :class:`ResolutionStrategy`.
457 :raises TypeError: If parameter 'limit' is not of type :class:`int`.
458 :raises ValueError: If parameter 'limit' is negative.
459 :raises ValueError: If parameter 'preReleases' is ``None``.
460 :raises TypeError: If parameter 'preReleases' is not of type :class:`PreReleasePolicy`.
461 :raises TypeError: If parameter 'environment' is not a mapping.
462 """
463 if index is None:
464 raise ValueError("Parameter 'index' is None.")
465 elif not isinstance(index, PythonPackageIndex):
466 ex = TypeError("Parameter 'index' is not of type 'PythonPackageIndex'.")
467 ex.add_note(f"Got type '{getFullyQualifiedName(index)}'.")
468 raise ex
470 if strategy is None:
471 raise ValueError("Parameter 'strategy' is None.")
472 elif not isinstance(strategy, ResolutionStrategy):
473 ex = TypeError("Parameter 'strategy' is not of type 'ResolutionStrategy'.")
474 ex.add_note(f"Got type '{getFullyQualifiedName(strategy)}'.")
475 raise ex
477 if limit is not None and (not isinstance(limit, int) or isinstance(limit, bool)):
478 ex = TypeError("Parameter 'limit' is not of type 'int'.")
479 ex.add_note(f"Got type '{getFullyQualifiedName(limit)}'.")
480 raise ex
481 elif limit is not None and limit < 0:
482 ex = ValueError("Parameter 'limit' is negative.")
483 ex.add_note(f"Got value '{limit}'.")
484 raise ex
486 if preReleases is None:
487 raise ValueError("Parameter 'preReleases' is None.")
488 elif not isinstance(preReleases, PreReleasePolicy):
489 ex = TypeError("Parameter 'preReleases' is not of type 'PreReleasePolicy'.")
490 ex.add_note(f"Got type '{getFullyQualifiedName(preReleases)}'.")
491 raise ex
493 if environment is not None and not isinstance(environment, Mapping):
494 ex = TypeError("Parameter 'environment' is not a mapping.")
495 ex.add_note(f"Got type '{getFullyQualifiedName(environment)}'.")
496 raise ex
498 self._index = index
499 self._strategy = strategy
500 self._limit = limit
501 self._preReleases = preReleases
502 self._environment = None if environment is None else dict(environment)
503 self._undescribed = set()
504 self._failed = set()
506 @readonly
507 def Index(self) -> PythonPackageIndex:
508 """
509 Read-only property to access the package index the requirements are resolved against (:attr:`_index`).
511 :returns: The package index.
512 """
513 return self._index
515 @readonly
516 def Strategy(self) -> ResolutionStrategy:
517 """
518 Read-only property to access when the requirements of a selected release are resolved (:attr:`_strategy`).
520 :returns: The strategy.
521 """
522 return self._strategy
524 @readonly
525 def Limit(self) -> Nullable[int]:
526 """
527 Read-only property to access the number of levels resolved below the given requirements (:attr:`_limit`).
529 :returns: The limit, or ``None`` for unlimited.
530 """
531 return self._limit
533 @readonly
534 def PreReleases(self) -> PreReleasePolicy:
535 """
536 Read-only property to access whether pre-releases are selected (:attr:`_preReleases`).
538 :returns: The pre-release policy.
539 """
540 return self._preReleases
542 @readonly
543 def Environment(self) -> Nullable[dict[str, str]]:
544 """
545 Read-only property to access the environment markers are evaluated against (:attr:`_environment`).
547 :returns: The environment, or ``None`` if markers aren't evaluated.
548 """
549 return self._environment
551 def ResolveRequirements(self, requirements: Iterable[Requirement]) -> Resolution:
552 """
553 Resolve requirements.
555 A project required twice is resolved once: the later requirement replaces the earlier one, in the earlier one's
556 position.
558 :param requirements: The requirements to resolve.
559 :returns: The resolution.
560 :raises ValueError: If parameter 'requirements' is ``None``.
561 :raises TypeError: If parameter 'requirements' is not iterable.
562 :raises TypeError: If an element of parameter 'requirements' is not of type
563 :class:`~packaging.requirements.Requirement`.
564 :raises UnhandledCriticalWarningError: If a critical warning (see :class:`Resolver`) is reported and no
565 :class:`~pyTooling.Warning.WarningCollector` collects it.
566 """
567 if requirements is None:
568 raise ValueError("Parameter 'requirements' is None.")
569 elif not isinstance(requirements, Iterable):
570 ex = TypeError("Parameter 'requirements' is not iterable.")
571 ex.add_note(f"Got type '{getFullyQualifiedName(requirements)}'.")
572 raise ex
574 flattened: dict[str, tuple[Requirement, Nullable[str]]] = {}
575 for requirement in requirements:
576 if not isinstance(requirement, Requirement):
577 ex = TypeError("An element of parameter 'requirements' is not of type 'Requirement'.")
578 ex.add_note(f"Got type '{getFullyQualifiedName(requirement)}'.")
579 raise ex
581 flattened[canonicalize_name(requirement.name)] = (requirement, None)
583 return self._Resolve(flattened.values())
585 def ResolveRequirementsFile(self, requirementsFile: RequirementsFile) -> Resolution:
586 """
587 Resolve what a requirements file and the files it references require.
589 The requirements are taken from :attr:`~pyTooling.Dependency.Python.RequirementsFile.AllRequirements`, so the
590 nearer statement of a project wins.
592 :param requirementsFile: The requirements file, e.g. read by
593 :meth:`~pyTooling.Dependency.Python.RequirementsFile.Parse`.
594 :returns: The resolution.
595 :raises ValueError: If parameter 'requirementsFile' is ``None``.
596 :raises TypeError: If parameter 'requirementsFile' is not of type
597 :class:`~pyTooling.Dependency.Python.RequirementsFile`.
598 :raises UnhandledCriticalWarningError: If a critical warning (see :class:`Resolver`) is reported and no
599 :class:`~pyTooling.Warning.WarningCollector` collects it.
600 """
601 if requirementsFile is None:
602 raise ValueError("Parameter 'requirementsFile' is None.")
603 elif not isinstance(requirementsFile, RequirementsFile):
604 ex = TypeError("Parameter 'requirementsFile' is not of type 'RequirementsFile'.")
605 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.")
606 raise ex
608 return self.ResolveRequirements(requirementsFile.AllRequirements)
610 def ResolvePackages(self, packages: Iterable[tuple[str, Nullable[str]]]) -> Resolution:
611 """
612 Resolve what the newest releases of packages require, or the requirements of one of their extras.
614 The newest release is the newest one the :class:`PreReleasePolicy` admits.
616 The packages' requirements are flattened in the order the packages are given, a later package's requirement of a
617 project replacing an earlier one's.
619 :param packages: Pairs of a package name and an extra's name, or ``None`` for the package's
620 own requirements.
621 :returns: The resolution.
622 :raises ValueError: If parameter 'packages' is ``None``.
623 :raises TypeError: If parameter 'packages' is not iterable.
624 :raises TypeError: If an element of parameter 'packages' is not a pair of a :class:`str` and an
625 optional :class:`str`.
626 :raises ProjectNotFoundError: If the index doesn't know a package.
627 :raises ReleaseNotFoundError: If a package has no release.
628 :raises ReleaseNotFoundError: If the index can't describe a package's newest release.
629 :raises ExtraNotFoundError: If a package's newest release doesn't declare the extra.
630 :raises UnhandledCriticalWarningError: If a critical warning (see :class:`Resolver`) is reported and no
631 :class:`~pyTooling.Warning.WarningCollector` collects it.
632 """
633 if packages is None:
634 raise ValueError("Parameter 'packages' is None.")
635 elif not isinstance(packages, Iterable):
636 ex = TypeError("Parameter 'packages' is not iterable.")
637 ex.add_note(f"Got type '{getFullyQualifiedName(packages)}'.")
638 raise ex
640 flattened: dict[str, tuple[Requirement, Nullable[str]]] = {}
641 for package in packages:
642 if (
643 not isinstance(package, tuple) or len(package) != 2 or not isinstance(package[0], str)
644 or (package[1] is not None and not isinstance(package[1], str))
645 ):
646 ex = TypeError("An element of parameter 'packages' is not a pair of a 'str' and an optional 'str'.")
647 ex.add_note(f"Got '{package!r}'.")
648 raise ex
650 packageName, extra = package
651 if (release := self._SelectRelease(self._index.DownloadProject(packageName), SpecifierSet())) is None:
652 raise ReleaseNotFoundError(f"Package '{packageName}' has no release.", projectName=packageName)
654 declared = {canonicalize_name(name): name for name in release.Requirements if name is not None}
655 if extra is None:
656 requirements = release.Requirements[None]
657 elif (declaredExtra := declared.get(canonicalize_name(extra), None)) is not None:
658 requirements = release.Requirements[declaredExtra]
659 else:
660 ex = ExtraNotFoundError(
661 f"Package '{packageName}' has no extra '{extra}'.",
662 projectName=packageName,
663 version=release.Version,
664 extra=extra
665 )
666 ex.add_note(f"Known are: {', '.join(sorted(declared.values())) or 'none'}.")
667 raise ex
669 for requirement in requirements:
670 flattened[canonicalize_name(requirement.name)] = (requirement, extra)
672 return self._Resolve(flattened.values())
674 def _Resolve(self, requirements: Iterable[tuple[Requirement, Nullable[str]]]) -> Resolution:
675 """
676 Resolve the given requirements and, with the strategy :attr:`~ResolutionStrategy.Eager`, everything below them.
678 :param requirements: Pairs of a requirement and the extra it belongs to.
679 :returns: The resolution.
680 """
681 resolution = Resolution(self)
682 for requirement, extra in requirements:
683 resolution._requirements.append(ResolvedRequirement(requirement, resolution, extra=extra))
685 self._ResolveLevel(resolution._requirements)
687 if self._strategy is ResolutionStrategy.Eager:
688 level = resolution._requirements
689 while len(level) > 0:
690 self._ResolveDependencies(level)
691 level = [dependency for requirement in level for dependency in requirement._dependencies]
693 return resolution
695 def _ResolveDependencies(self, requirements: Iterable[ResolvedRequirement]) -> None:
696 """
697 Create the dependencies of resolved requirements and resolve them, all downloads together.
699 A release's dependencies are its unconditional requirements, followed by the requirements of each extra the
700 requirement names (``pyTooling[yaml]``). An extra the release doesn't declare is reported as a
701 :exc:`~pyTooling.Dependency.UndeclaredExtraWarning`.
703 :param requirements: Requirements resolved already, whose dependencies aren't created yet.
704 """
705 dependencies: list[ResolvedRequirement] = []
706 for requirement in requirements:
707 requirement._dependencies = []
708 if requirement._status is not ResolutionStatus.Resolved:
709 continue
711 release = requirement._release
712 declared = {canonicalize_name(extra): extra for extra in release.Requirements if extra is not None}
713 groups: list[tuple[Nullable[str], list[Requirement]]] = [(None, release.Requirements[None])]
714 for extra in sorted(requirement._requirement.extras):
715 if (declaredExtra := declared.get(canonicalize_name(extra), None)) is None:
716 WarningCollector.Raise(UndeclaredExtraWarning(
717 f"Requirement '{requirement._requirement}' names extra '{extra}', which release '{release.Version}' of "
718 f"package '{release.Package.Name}' doesn't declare.",
719 requirement=requirement,
720 extra=extra,
721 release=release
722 ))
723 continue
725 groups.append((declaredExtra, release.Requirements[declaredExtra]))
727 for extra, group in groups:
728 for dependency in group:
729 resolvedDependency = ResolvedRequirement(dependency, requirement._resolution, parent=requirement, extra=extra)
730 requirement._dependencies.append(resolvedDependency)
731 dependencies.append(resolvedDependency)
733 self._ResolveLevel(dependencies)
735 def _ResolveLevel(self, requirements: list[ResolvedRequirement]) -> None:
736 """
737 Resolve requirements: check marker, cycle and limit, then look up the projects and describe the selected
738 releases, the downloads of each step in parallel.
740 :param requirements: The requirements to resolve.
741 """
742 toLookUp: list[ResolvedRequirement] = []
743 for requirement in requirements:
744 if self._environment is not None and requirement._requirement.marker is not None:
745 environment = dict(self._environment)
746 environment["extra"] = requirement._extra if requirement._extra is not None else ""
747 if not requirement._requirement.marker.evaluate(environment):
748 requirement._status = ResolutionStatus.MarkerNotApplying
749 continue
751 name = canonicalize_name(requirement._requirement.name)
752 ancestor = requirement._parent
753 while ancestor is not None and canonicalize_name(ancestor._requirement.name) != name:
754 ancestor = ancestor._parent
756 if ancestor is not None:
757 requirement._status = ResolutionStatus.Cycle
758 requirement._project = ancestor._project
759 requirement._release = ancestor._release
760 requirement._parent._release.AddDependencyToPackageVersion(ancestor._release)
761 elif self._limit is not None and requirement._level > self._limit:
762 requirement._status = ResolutionStatus.LimitReached
763 else:
764 toLookUp.append(requirement)
766 if Timeout.IsExpired():
767 self._ExpireRequirements(toLookUp)
768 return
770 timedOutProjects = self._LookUpProjects(toLookUp)
772 toDescribe: list[ResolvedRequirement] = []
773 timedOut: list[ResolvedRequirement] = []
774 for requirement in toLookUp:
775 if requirement._project in timedOutProjects:
776 requirement._project = None
777 timedOut.append(requirement)
778 continue
779 elif requirement._project is None:
780 requirement._status = ResolutionStatus.UnknownProject
781 WarningCollector.Raise(UnresolvedRequirementWarning(
782 f"Package '{requirement._requirement.name}' is unknown to the package index.",
783 projectName=requirement._requirement.name
784 ))
785 continue
787 if requirement._project in self._failed:
788 requirement._status = ResolutionStatus.IndexFailure
789 continue
791 if (release := self._SelectRelease(requirement._project, requirement._requirement.specifier)) is None:
792 requirement._status = ResolutionStatus.NoMatchingRelease
793 continue
795 requirement._release = release
796 toDescribe.append(requirement)
798 timedOutReleases = self._DescribeReleases(toDescribe)
800 for requirement in toDescribe:
801 if requirement._release in timedOutReleases:
802 requirement._release = None
803 timedOut.append(requirement)
804 continue
805 elif requirement._release in self._failed:
806 requirement._status = ResolutionStatus.IndexFailure
807 continue
808 elif requirement._release in self._undescribed:
809 requirement._status = ResolutionStatus.UndescribedRelease
810 WarningCollector.Raise(UnresolvedRequirementWarning(
811 f"The package index can't describe release '{requirement._release.Version}' of package "
812 f"'{requirement._project.Name}'.",
813 projectName=requirement._project.Name,
814 version=requirement._release.Version
815 ))
816 continue
818 requirement._status = ResolutionStatus.Resolved
819 if (parent := requirement._parent) is not None:
820 parent._release.AddDependencyToPackageVersion(requirement._release)
822 if len(timedOut) > 0:
823 self._ExpireRequirements(timedOut)
825 def _ExpireRequirements(self, requirements: list[ResolvedRequirement]) -> None:
826 """
827 Give requirements the status :attr:`~ResolutionStatus.TimeoutExpired` and report them as one critical
828 :exc:`~pyTooling.Dependency.ResolutionTimeoutWarning`.
830 :param requirements: The requirements the expired timeout left unresolved.
831 """
832 if len(requirements) == 0:
833 return
835 for requirement in requirements:
836 requirement._status = ResolutionStatus.TimeoutExpired
838 seconds = Timeout.Current().Seconds
839 WarningCollector.Raise(ResolutionTimeoutWarning(
840 f"Timeout of {seconds} seconds expired; {len(requirements)} requirements weren't resolved.",
841 seconds=seconds,
842 projectNames=(requirement._requirement.name for requirement in requirements)
843 ))
845 def _LookUpProjects(self, requirements: list[ResolvedRequirement]) -> set[Project]:
846 """
847 Look up the required projects on the index, downloading those not loaded yet in parallel.
849 A project the index answers ``404 Not Found`` for is forgotten by the index and left ``None``. A project the index
850 fails to answer for is remembered as failed and reported as a critical
851 :exc:`~pyTooling.Dependency.PackageIndexFailureWarning`. A download the current timeout cut off is neither: the
852 project stays unloaded, so a later resolution asks for it again.
854 :param requirements: The requirements whose project is looked up.
855 :returns: The projects whose download the current timeout cut off.
856 """
857 toDownload: dict[str, Project] = {}
858 for requirement in requirements:
859 name = requirement._requirement.name
860 if canonicalize_name(name) in self._index._unknownProjects: 860 ↛ 861line 860 didn't jump to line 861 because the condition on line 860 was never true
861 continue
863 project = self._index.DownloadProject(name, LazyLoaderState.Initialized)
864 if project not in self._failed and project.__lazy_state__ < LazyLoaderState.PartiallyLoaded:
865 toDownload[canonicalize_name(name)] = project
867 timedOut: set[Project] = set()
868 projects = list(toDownload.values())
869 documents = self._Download([project._GetPyPIEndpoint() for project in projects])
870 for project, document in zip(projects, documents):
871 if isinstance(document, TimeoutError) and Timeout.Current() is not None:
872 timedOut.add(project)
873 elif isinstance(document, BaseException):
874 self._failed.add(project)
875 WarningCollector.Raise(
876 PackageIndexFailureWarning(
877 f"Package index failed to answer for package '{project.Name}': {document}",
878 projectName=project.Name,
879 statusCode=document.status if isinstance(document, ClientResponseError) else None
880 ),
881 document
882 )
883 elif document is None:
884 self._index._ForgetProject(project)
885 else:
886 project.UpdateDetailsFromPyPIJSON(document)
888 for requirement in requirements:
889 requirement._project = self._index._projectCache.get(canonicalize_name(requirement._requirement.name), None)
891 return timedOut
893 def _DescribeReleases(self, requirements: list[ResolvedRequirement]) -> set[Release]:
894 """
895 Download the details of the selected releases not described yet, in parallel.
897 A release the index can't describe - it answers ``404 Not Found``, or the details can't be read - is remembered
898 as undescribed. A release the index fails to answer for is remembered as failed and reported as a critical
899 :exc:`~pyTooling.Dependency.PackageIndexFailureWarning`. A download the current timeout cut off is neither: the
900 release stays undescribed, so a later resolution asks for it again.
902 :param requirements: The requirements whose selected release is described.
903 :returns: The releases whose download the current timeout cut off.
904 """
905 toDownload: dict[int, Release] = {}
906 for requirement in requirements:
907 release = requirement._release
908 if release in self._undescribed or release in self._failed: 908 ↛ 909line 908 didn't jump to line 909 because the condition on line 908 was never true
909 continue
910 elif release.__lazy_state__ < LazyLoaderState.PartiallyLoaded:
911 toDownload[id(release)] = release
913 timedOut: set[Release] = set()
914 releases = list(toDownload.values())
915 documents = self._Download([release._GetPyPIEndpoint() for release in releases])
916 for release, document in zip(releases, documents):
917 if isinstance(document, TimeoutError) and Timeout.Current() is not None:
918 timedOut.add(release)
919 continue
920 elif isinstance(document, BaseException):
921 self._failed.add(release)
922 WarningCollector.Raise(
923 PackageIndexFailureWarning(
924 f"Package index failed to answer for release '{release.Version}' of package '{release.Package.Name}': "
925 f"{document}",
926 projectName=release.Package.Name,
927 version=release.Version,
928 statusCode=document.status if isinstance(document, ClientResponseError) else None
929 ),
930 document
931 )
932 continue
933 elif document is None:
934 self._undescribed.add(release)
935 continue
937 try:
938 release.UpdateDetailsFromPyPIJSON(document)
939 except (KeyError, ValueError) as ex:
940 self._undescribed.add(release)
941 WarningCollector.Raise(
942 UnresolvedRequirementWarning(
943 f"Details of release '{release.Version}' of package '{release.Package.Name}' can't be read.",
944 projectName=release.Package.Name,
945 version=release.Version
946 ),
947 ex
948 )
950 return timedOut
952 def _SelectRelease(self, project: Project, specifier: SpecifierSet) -> Nullable[Release]:
953 """
954 Select the newest release of a project matching a specifier, as far as the pre-release policy admits it.
956 :param project: The project to select a release of.
957 :param specifier: The specifier the release has to match.
958 :returns: The newest matching release, or ``None`` if none matches.
959 """
960 preReleases = True if self._preReleases is PreReleasePolicy.Include else None
961 releases = {str(version): release for version, release in project.Releases.items()}
962 matching = [releases[version] for version in specifier.filter(releases, preReleases)]
964 return max(matching, key=lambda release: release.Version) if len(matching) > 0 else None
966 def _Download(self, endpoints: list[str]) -> list[Any]:
967 """
968 Download JSON documents from the package index's API in parallel, over one session.
970 While a :class:`~pyTooling.Timeout.Timeout` is current, the session's total timeout is the time it has left, so a
971 download it cuts off returns a :exc:`TimeoutError`.
973 :param endpoints: The endpoints, relative to the index's API URL.
974 :returns: Per endpoint, the parsed document, ``None`` for ``404 Not Found``, or the exception raised.
975 """
976 if len(endpoints) == 0:
977 return []
978 elif (remaining := Timeout.Remaining()) == 0.0: 978 ↛ 979line 978 didn't jump to line 979 because the condition on line 978 was never true
979 return [TimeoutError("Timeout expired before the download.") for _ in endpoints]
981 async def downloadAll() -> list[Any]:
982 """
983 Nested coroutine downloading every endpoint over one shared session.
985 :returns: Per endpoint, the parsed document, ``None`` for ``404 Not Found``, or the exception raised.
986 """
987 async def download(session: ClientSession, endpoint: str) -> Nullable[Any]:
988 """
989 Nested coroutine downloading a single endpoint.
991 :param session: The HTTP session shared by all requests of this download.
992 :param endpoint: The endpoint, relative to the index's API URL.
993 :returns: The parsed document, or ``None`` for ``404 Not Found``.
994 """
995 async with session.get(endpoint) as response:
996 if response.status == 404:
997 return None
999 response.raise_for_status()
1000 return await response.json()
1002 arguments: dict[str, Any] = {"base_url": str(self._index._api), "headers": {"accept": "application/json"}}
1003 if remaining is not None:
1004 arguments["timeout"] = ClientTimeout(total=remaining)
1006 async with ClientSession(**arguments) as session:
1007 return await asyncio_gather(*(download(session, endpoint) for endpoint in endpoints), return_exceptions=True)
1009 return asyncio_run(downloadAll())
1012_Resolver = Resolver
1015@export
1016class CurrentEnvironmentResolver(Resolver):
1017 """
1018 A :class:`Resolver` evaluating environment markers against the running interpreter
1019 (:func:`packaging.markers.default_environment`).
1020 """
1022 def __init__(
1023 self,
1024 index: PythonPackageIndex,
1025 *,
1026 strategy: ResolutionStrategy = ResolutionStrategy.DEFAULT,
1027 limit: Nullable[int] = None,
1028 preReleases: PreReleasePolicy = PreReleasePolicy.DEFAULT
1029 ) -> None:
1030 """
1031 Initializes a resolver for the running interpreter's environment.
1033 :param index: The package index the requirements are resolved against.
1034 :param strategy: Optional, when the requirements of a selected release are resolved. Default:
1035 :attr:`~ResolutionStrategy.Lazy`.
1036 :param limit: Optional, number of levels resolved below the given requirements; ``0`` resolves only the
1037 given requirements. Default: ``None``, unlimited.
1038 :param preReleases: Optional, whether pre-releases are selected. Default: :attr:`~PreReleasePolicy.Exclude`.
1039 :raises ValueError: If parameter 'index' is ``None``.
1040 :raises TypeError: If parameter 'index' is not of type :class:`~pyTooling.Dependency.Python.PythonPackageIndex`.
1041 :raises ValueError: If parameter 'strategy' is ``None``.
1042 :raises TypeError: If parameter 'strategy' is not of type :class:`ResolutionStrategy`.
1043 :raises TypeError: If parameter 'limit' is not of type :class:`int`.
1044 :raises ValueError: If parameter 'limit' is negative.
1045 :raises ValueError: If parameter 'preReleases' is ``None``.
1046 :raises TypeError: If parameter 'preReleases' is not of type :class:`PreReleasePolicy`.
1047 """
1048 super().__init__(
1049 index,
1050 strategy=strategy,
1051 limit=limit,
1052 preReleases=preReleases,
1053 environment=default_environment()
1054 )