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

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. 

32 

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. 

37 

38Importing this module needs the ``dependencies`` extra (:mod:`aiohttp`, :mod:`packaging`), like 

39:mod:`pyTooling.Dependency.Python`. 

40 

41:raises MissingDependencyError: If the 'dependencies' extra isn't installed. 

42""" 

43from __future__ import annotations 

44 

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 

48 

49from pyTooling.Exceptions import MissingDependencyError 

50 

51try: 

52 from aiohttp import ClientResponseError, ClientSession, ClientTimeout 

53except ImportError as ex: # pragma: no cover 

54 raise MissingDependencyError(dependency="aiohttp", extra="dependencies") from ex 

55 

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 

63 

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 

72 

73_Requirement = Requirement 

74_Project = Project 

75_Release = Release 

76 

77 

78@export 

79class ResolutionStrategy(StringEnum): 

80 """When a :class:`Resolver` resolves the requirements of a selected release.""" 

81 

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. 

84 

85 DEFAULT = Lazy #: The strategy used if none is given. 

86 

87 

88@export 

89class PreReleasePolicy(StringEnum): 

90 """ 

91 Whether a :class:`Resolver` selects pre-releases. 

92 

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 """ 

96 

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. 

99 

100 DEFAULT = Exclude #: The policy used if none is given. 

101 

102 

103@export 

104class ResolutionStatus(Enum): 

105 """What resolving a requirement came to.""" 

106 

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. 

116 

117 

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. 

122 

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 """ 

126 

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. 

136 

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. 

147 

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 

166 

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 

173 

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 

178 

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 

183 

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 

193 

194 @readonly 

195 def Requirement(self) -> Requirement: 

196 """ 

197 Read-only property to access the requirement as stated (:attr:`_requirement`). 

198 

199 :returns: The requirement. 

200 """ 

201 return self._requirement 

202 

203 @readonly 

204 def Parent(self) -> Nullable[ResolvedRequirement]: 

205 """ 

206 Read-only property to access the requirement whose release requires this one (:attr:`_parent`). 

207 

208 :returns: The requiring requirement, or ``None`` for a requirement given to the resolver. 

209 """ 

210 return self._parent 

211 

212 @readonly 

213 def Extra(self) -> Nullable[str]: 

214 """ 

215 Read-only property to access the extra the requirement belongs to (:attr:`_extra`). 

216 

217 :returns: The extra's name, or ``None`` for an unconditional requirement. 

218 """ 

219 return self._extra 

220 

221 @readonly 

222 def Level(self) -> int: 

223 """ 

224 Read-only property to access the number of requirements above this one (:attr:`_level`). 

225 

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 

229 

230 @readonly 

231 def Status(self) -> Nullable[ResolutionStatus]: 

232 """ 

233 Read-only property to access what resolving the requirement came to (:attr:`_status`). 

234 

235 :returns: The status, or ``None`` while the requirement isn't resolved yet. 

236 """ 

237 return self._status 

238 

239 @readonly 

240 def Project(self) -> Nullable[Project]: 

241 """ 

242 Read-only property to access the required project (:attr:`_project`). 

243 

244 :returns: The project, or ``None`` if the index doesn't know it or it wasn't looked up. 

245 """ 

246 return self._project 

247 

248 @readonly 

249 def Release(self) -> Nullable[Release]: 

250 """ 

251 Read-only property to access the selected release (:attr:`_release`). 

252 

253 For a requirement closing a cycle, it's the release selected further up the path. 

254 

255 :returns: The release, or ``None`` if none was selected. 

256 """ 

257 return self._release 

258 

259 @readonly 

260 def Dependencies(self) -> tuple[ResolvedRequirement, ...]: 

261 """ 

262 Read-only property to return the release's requirements, resolved. 

263 

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`. 

266 

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, )) 

275 

276 return tuple(self._dependencies) 

277 

278 def IterateDependencies( 

279 self, 

280 predicate: Nullable[Callable[[ResolvedRequirement], bool]] = None 

281 ) -> Iterator[ResolvedRequirement]: 

282 """ 

283 Iterate the release's requirements, resolved. 

284 

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 

294 

295 def __str__(self) -> str: 

296 """ 

297 Return the requirement and what resolving it came to. 

298 

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}" 

303 

304 status = "not resolved" if self._status is None else self._status.name 

305 return f"{self._requirement} ({status})" 

306 

307 

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 """ 

313 

314 _resolver: _Resolver #: The resolver that created the resolution. 

315 _requirements: list[ResolvedRequirement] #: The requirements given to the resolver, resolved. 

316 

317 def __init__(self, resolver: Resolver) -> None: 

318 """ 

319 Initializes an empty resolution. 

320 

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 

331 

332 self._resolver = resolver 

333 self._requirements = [] 

334 

335 @readonly 

336 def Resolver(self) -> Resolver: 

337 """ 

338 Read-only property to access the resolver that created the resolution (:attr:`_resolver`). 

339 

340 :returns: The resolver. 

341 """ 

342 return self._resolver 

343 

344 @readonly 

345 def Requirements(self) -> tuple[ResolvedRequirement, ...]: 

346 """ 

347 Read-only property to return the requirements given to the resolver, resolved (:attr:`_requirements`). 

348 

349 :returns: Tuple of resolved requirements, in the order they were given. 

350 """ 

351 return tuple(self._requirements) 

352 

353 @readonly 

354 def RequirementCount(self) -> int: 

355 """ 

356 Read-only property to return the number of requirements given to the resolver. 

357 

358 :returns: Number of requirements. 

359 """ 

360 return len(self._requirements) 

361 

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. 

368 

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 

378 

379 

380@export 

381class Resolver(metaclass=ExtendedType, slots=True): 

382 """ 

383 Resolves requirements into releases of a Python package index. 

384 

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. 

391 

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. 

398 

399 .. important:: 

400 

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). 

407 

408 .. attention:: 

409 

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. 

412 

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. 

417 

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 """ 

424 

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. 

432 

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. 

444 

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 

469 

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 

476 

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 

485 

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 

492 

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 

497 

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() 

505 

506 @readonly 

507 def Index(self) -> PythonPackageIndex: 

508 """ 

509 Read-only property to access the package index the requirements are resolved against (:attr:`_index`). 

510 

511 :returns: The package index. 

512 """ 

513 return self._index 

514 

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`). 

519 

520 :returns: The strategy. 

521 """ 

522 return self._strategy 

523 

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`). 

528 

529 :returns: The limit, or ``None`` for unlimited. 

530 """ 

531 return self._limit 

532 

533 @readonly 

534 def PreReleases(self) -> PreReleasePolicy: 

535 """ 

536 Read-only property to access whether pre-releases are selected (:attr:`_preReleases`). 

537 

538 :returns: The pre-release policy. 

539 """ 

540 return self._preReleases 

541 

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`). 

546 

547 :returns: The environment, or ``None`` if markers aren't evaluated. 

548 """ 

549 return self._environment 

550 

551 def ResolveRequirements(self, requirements: Iterable[Requirement]) -> Resolution: 

552 """ 

553 Resolve requirements. 

554 

555 A project required twice is resolved once: the later requirement replaces the earlier one, in the earlier one's 

556 position. 

557 

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 

573 

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 

580 

581 flattened[canonicalize_name(requirement.name)] = (requirement, None) 

582 

583 return self._Resolve(flattened.values()) 

584 

585 def ResolveRequirementsFile(self, requirementsFile: RequirementsFile) -> Resolution: 

586 """ 

587 Resolve what a requirements file and the files it references require. 

588 

589 The requirements are taken from :attr:`~pyTooling.Dependency.Python.RequirementsFile.AllRequirements`, so the 

590 nearer statement of a project wins. 

591 

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 

607 

608 return self.ResolveRequirements(requirementsFile.AllRequirements) 

609 

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. 

613 

614 The newest release is the newest one the :class:`PreReleasePolicy` admits. 

615 

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. 

618 

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 

639 

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 

649 

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) 

653 

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 

668 

669 for requirement in requirements: 

670 flattened[canonicalize_name(requirement.name)] = (requirement, extra) 

671 

672 return self._Resolve(flattened.values()) 

673 

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. 

677 

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)) 

684 

685 self._ResolveLevel(resolution._requirements) 

686 

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] 

692 

693 return resolution 

694 

695 def _ResolveDependencies(self, requirements: Iterable[ResolvedRequirement]) -> None: 

696 """ 

697 Create the dependencies of resolved requirements and resolve them, all downloads together. 

698 

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`. 

702 

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 

710 

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 

724 

725 groups.append((declaredExtra, release.Requirements[declaredExtra])) 

726 

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) 

732 

733 self._ResolveLevel(dependencies) 

734 

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. 

739 

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 

750 

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 

755 

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) 

765 

766 if Timeout.IsExpired(): 

767 self._ExpireRequirements(toLookUp) 

768 return 

769 

770 timedOutProjects = self._LookUpProjects(toLookUp) 

771 

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 

786 

787 if requirement._project in self._failed: 

788 requirement._status = ResolutionStatus.IndexFailure 

789 continue 

790 

791 if (release := self._SelectRelease(requirement._project, requirement._requirement.specifier)) is None: 

792 requirement._status = ResolutionStatus.NoMatchingRelease 

793 continue 

794 

795 requirement._release = release 

796 toDescribe.append(requirement) 

797 

798 timedOutReleases = self._DescribeReleases(toDescribe) 

799 

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 

817 

818 requirement._status = ResolutionStatus.Resolved 

819 if (parent := requirement._parent) is not None: 

820 parent._release.AddDependencyToPackageVersion(requirement._release) 

821 

822 if len(timedOut) > 0: 

823 self._ExpireRequirements(timedOut) 

824 

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`. 

829 

830 :param requirements: The requirements the expired timeout left unresolved. 

831 """ 

832 if len(requirements) == 0: 

833 return 

834 

835 for requirement in requirements: 

836 requirement._status = ResolutionStatus.TimeoutExpired 

837 

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 )) 

844 

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. 

848 

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. 

853 

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 

862 

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 

866 

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) 

887 

888 for requirement in requirements: 

889 requirement._project = self._index._projectCache.get(canonicalize_name(requirement._requirement.name), None) 

890 

891 return timedOut 

892 

893 def _DescribeReleases(self, requirements: list[ResolvedRequirement]) -> set[Release]: 

894 """ 

895 Download the details of the selected releases not described yet, in parallel. 

896 

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. 

901 

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 

912 

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 

936 

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 ) 

949 

950 return timedOut 

951 

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. 

955 

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)] 

963 

964 return max(matching, key=lambda release: release.Version) if len(matching) > 0 else None 

965 

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. 

969 

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`. 

972 

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] 

980 

981 async def downloadAll() -> list[Any]: 

982 """ 

983 Nested coroutine downloading every endpoint over one shared session. 

984 

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. 

990 

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 

998 

999 response.raise_for_status() 

1000 return await response.json() 

1001 

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) 

1005 

1006 async with ClientSession(**arguments) as session: 

1007 return await asyncio_gather(*(download(session, endpoint) for endpoint in endpoints), return_exceptions=True) 

1008 

1009 return asyncio_run(downloadAll()) 

1010 

1011 

1012_Resolver = Resolver 

1013 

1014 

1015@export 

1016class CurrentEnvironmentResolver(Resolver): 

1017 """ 

1018 A :class:`Resolver` evaluating environment markers against the running interpreter 

1019 (:func:`packaging.markers.default_environment`). 

1020 """ 

1021 

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. 

1032 

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 )