Coverage for pyTooling/Dependency/Python.py: 68%

336 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-22 21:29 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ ___ _ __ ___ _ __ __| | ___ _ __ ___ _ _ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | | | |/ _ \ '_ \ / _ \ '_ \ / _` |/ _ \ '_ \ / __| | | | # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| |_| | __/ |_) | __/ | | | (_| | __/ | | | (__| |_| | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \___| .__/ \___|_| |_|\__,_|\___|_| |_|\___|\__, | # 

7# |_| |___/ |___/ |_| |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2025-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32Implementation of package dependencies. 

33 

34.. hint:: 

35 

36 See :ref:`high-level help <DEPENDENCIES>` for explanations and usage examples. 

37""" 

38from __future__ import annotations 

39 

40from asyncio import run as asyncio_run, gather as asyncio_gather 

41from datetime import datetime 

42from enum import IntEnum 

43from functools import wraps, update_wrapper 

44from threading import RLock 

45from typing import Optional as Nullable, Union, Iterable, Mapping 

46 

47from pyTooling.Exceptions import MissingDependencyException 

48 

49try: 

50 from aiohttp import ClientSession 

51except ImportError as ex: # pragma: no cover 

52 raise MissingDependencyException(dependency="aiohttp", extra="pypi") from ex 

53 

54try: 

55 from packaging.requirements import Requirement 

56except ImportError as ex: # pragma: no cover 

57 raise MissingDependencyException(dependency="packaging", extra="pypi") from ex 

58 

59try: 

60 from requests import Session, HTTPError 

61except ImportError as ex: # pragma: no cover 

62 raise MissingDependencyException(dependency="requests", extra="pypi") from ex 

63 

64from pyTooling.Decorators import export, readonly 

65from pyTooling.MetaClasses import ExtendedType, abstractmethod 

66from pyTooling.Common import getFullyQualifiedName, firstValue 

67from pyTooling.Dependency import Package, PackageStorage, PackageVersion, PackageDependencyGraph 

68from pyTooling.Dependency import BrokenRequirementWarning, NoSessionAvailableException, ProjectNotFoundException 

69from pyTooling.Dependency import ReleaseDetailsWarning, ReleaseNotFoundException 

70from pyTooling.Warning import WarningCollector 

71from pyTooling.GenericPath.URL import URL 

72from pyTooling.Versioning import SemanticVersion, PythonVersion, Parts 

73 

74 

75@export 

76class LazyLoaderState(IntEnum): 

77 """ 

78 Loading states of a lazy-loadable object, in the order they are reached. 

79 

80 The states are ordered, so a loader can be asked for *at least* a given state and does nothing when the object is 

81 already loaded that far. 

82 """ 

83 Uninitialized = 0 #: No data or minimal data like ID or name. 

84 Initialized = 1 #: Initialized by some __init__ parameters. 

85 PartiallyLoaded = 2 #: Some additional data was loaded. 

86 FullyLoaded = 3 #: All data is loaded. 

87 PostProcessed = 4 #: Loaded data triggered further processing. 

88 

89 

90@export 

91class lazy: 

92 """ 

93 Unified decorator that supports: 

94 1. @lazy(state) def method() 

95 2. @lazy(state) @property def prop() 

96 """ 

97 

98 def __init__(self, _requiredState: LazyLoaderState = LazyLoaderState.PartiallyLoaded): 

99 """ 

100 Initialize the decorator with the loading state its member needs. 

101 

102 :param _requiredState: Optional, state the object has to be loaded to before the decorated member is used. 

103 """ 

104 self._requiredState = _requiredState 

105 self._wrapped = None 

106 

107 def __call__(self, wrapped): 

108 """ 

109 Apply the decorator to a method or property. 

110 

111 :param wrapped: The method or property to load lazily. 

112 :returns: The decorator itself, which acts as the descriptor of the decorated member. 

113 """ 

114 self._wrapped = wrapped 

115 # If it's a function, we update metadata. 

116 # If it's a property, it doesn't support update_wrapper directly. 

117 if hasattr(wrapped, "__name__"): 

118 update_wrapper(self, wrapped) 

119 

120 return self 

121 

122 def __get__(self, obj, objtype=None): 

123 """ 

124 Load the object far enough, then hand out the decorated property's value or a bound method. 

125 

126 :param obj: The object the decorated member is accessed on, or ``None`` for a class access. 

127 :param objtype: Optional, the class the decorated member is accessed on. 

128 :returns: The property's value, a bound wrapper around the method, or the decorator itself. 

129 """ 

130 if obj is None: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true

131 return self 

132 

133 # 1. Thread-safe state check 

134 with obj.__lazy_lock__: 

135 if obj.__lazy_state__ < self._requiredState: 

136 obj.__lazy_loader__(self._requiredState) 

137 

138 # 2. Determine if we are wrapping a property or a method 

139 if isinstance(self._wrapped, property): 

140 # If it's a property, call its __get__ to return the value 

141 return self._wrapped.__get__(obj, objtype) 

142 

143 # 3. Otherwise, treat as a method and return a bound wrapper 

144 @wraps(self._wrapped) 

145 def wrapper(*args, **kwargs): 

146 """ 

147 Nested function binding the decorated method to the object it was accessed on. 

148 

149 :param args: Positional parameters passed to the decorated method. 

150 :param kwargs: Named parameters passed to the decorated method. 

151 :returns: Whatever the decorated method returns. 

152 """ 

153 return self._wrapped(obj, *args, **kwargs) 

154 

155 return wrapper 

156 

157 

158@export 

159class LazyLoadableMixin(metaclass=ExtendedType, mixin=True): 

160 """ 

161 Mixin-class for objects whose details are fetched on first use. 

162 

163 The object is created from what its creator knows - often little more than a name - and everything else is loaded 

164 when it is needed. The mixin records how far the object is loaded (:attr:`__lazy_state__`) and serializes 

165 concurrent loading (:attr:`__lazy_lock__`); the deriving class implements a ``__lazy_loader__`` method and decides what 

166 loading means. 

167 """ 

168 __lazy_state__: LazyLoaderState #: State of the lazy loading process for this object. 

169 __lazy_lock__: RLock #: Lock serializing concurrent lazy loading of this object. 

170 

171 def __init__(self, targetLevel: LazyLoaderState = LazyLoaderState.Initialized) -> None: 

172 """ 

173 Initialize the lazy-loading state of an object. 

174 

175 :param targetLevel: Optional, state the object should be loaded to immediately; by default nothing is loaded. 

176 """ 

177 self.__lazy_state__ = LazyLoaderState.Initialized 

178 self.__lazy_lock__ = RLock() 

179 

180 if targetLevel > self.__lazy_state__: 

181 with self.__lazy_lock__: 

182 self.__lazy_loader__(targetLevel) 

183 

184 @abstractmethod 

185 def __lazy_loader__(self, targetLevel: LazyLoaderState) -> None: 

186 """ 

187 Load the object's details up to the given state. 

188 

189 :param targetLevel: Optional, state the object needs to be loaded to. 

190 """ 

191 pass 

192 

193 

194@export 

195class Distribution(metaclass=ExtendedType, slots=True): 

196 """ 

197 A single downloadable file of a release - a wheel or a source archive. 

198 """ 

199 _filename: str #: Filename of the distribution's file. 

200 _url: URL #: URL to download the distribution's file from. 

201 _uploadTime: datetime #: Time when the distribution was uploaded to the package index. 

202 

203 def __init__(self, filename: str, url: Union[str, URL], uploadTime: datetime) -> None: 

204 """ 

205 Initialize a distribution with the data the package index reports for it. 

206 

207 :param filename: Filename of the distribution's file. 

208 :param url: URL to download the file from, as a string or a parsed URL. 

209 :param uploadTime: Time the distribution was uploaded to the package index. 

210 :raises TypeError: If a parameter is not of the expected type. 

211 """ 

212 if not isinstance(filename, str): 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true

213 ex = TypeError("Parameter 'filename' is not of type 'str'.") 

214 ex.add_note(f"Got type '{getFullyQualifiedName(filename)}'.") 

215 raise ex 

216 

217 self._filename = filename 

218 

219 if isinstance(url, str): 219 ↛ 221line 219 didn't jump to line 221 because the condition on line 219 was always true

220 url = URL.Parse(url) 

221 elif not isinstance(url, URL): 

222 ex = TypeError("Parameter 'url' is not of type 'URL'.") 

223 ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.") 

224 raise ex 

225 

226 self._url = url 

227 

228 if not isinstance(uploadTime, datetime): 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true

229 ex = TypeError("Parameter 'uploadTime' is not of type 'str'.") 

230 ex.add_note(f"Got type '{getFullyQualifiedName(uploadTime)}'.") 

231 raise ex 

232 

233 self._uploadTime = uploadTime 

234 

235 @readonly 

236 def Filename(self) -> str: 

237 """ 

238 Read-only property to access the distribution's filename (:attr:`_filename`). 

239 

240 :returns: Filename of the distribution. 

241 """ 

242 return self._filename 

243 

244 @readonly 

245 def URL(self) -> URL: 

246 """ 

247 Read-only property to access the URL this distribution can be downloaded from (:attr:`_url`). 

248 

249 :returns: Download URL of the distribution. 

250 """ 

251 return self._url 

252 

253 @readonly 

254 def UploadTime(self) -> datetime: 

255 """ 

256 Read-only property to access the time this distribution was uploaded (:attr:`_uploadTime`). 

257 

258 :returns: Upload time of the distribution. 

259 """ 

260 return self._uploadTime 

261 

262 def __repr__(self) -> str: 

263 """ 

264 Return a detailed string representation of this distribution. 

265 

266 :returns: The distribution's filename, prefixed by its kind. 

267 """ 

268 return f"Distribution: {self._filename}" 

269 

270 def __str__(self) -> str: 

271 """ 

272 Return a string representation of this distribution. 

273 

274 :returns: The distribution's filename. 

275 """ 

276 return f"{self._filename}" 

277 

278 

279@export 

280class Release(PackageVersion, LazyLoadableMixin): 

281 """ 

282 One released version of a project on a Python package index. 

283 

284 A release knows its distributions (the files that can be downloaded) and its requirements, sorted into the extras 

285 they belong to. Both are fetched from the index on first use. 

286 """ 

287 _files: list[Distribution] #: Distributions (wheels, source archives) of this release. 

288 _requirements: dict[Union[str, None], list[Requirement]] #: Requirements per extra; ``None`` collects the unconditional ones. 

289 

290 _api: Nullable[URL] #: URL of the package index's API, used to load the release's details. 

291 _session: Nullable[Session] #: HTTP session reused for the API requests. 

292 

293 def __init__( 

294 self, 

295 version: PythonVersion, 

296 timestamp: datetime, 

297 files: Nullable[Iterable[Distribution]] = None, 

298 requirements: Nullable[Mapping[str, list[Requirement]]] = None, 

299 project: Nullable[Project] = None, 

300 lazy: LazyLoaderState = LazyLoaderState.Initialized 

301 ) -> None: 

302 """ 

303 Initialize a release of a project. 

304 

305 The API endpoint and the HTTP session are taken from the project's package index, so a release created from a 

306 project can fetch its own details. 

307 

308 :param version: Version number of this release. 

309 :param timestamp: Time this version was released. 

310 :param files: Optional, distributions of this release. 

311 :param requirements: Optional, requirements of this release, by extra. 

312 :param project: Optional, project this release belongs to. 

313 :param lazy: Optional, state the release should be loaded to immediately. 

314 """ 

315 if project is not None and (storage := project._storage) is not None: 315 ↛ 319line 315 didn't jump to line 319 because the condition on line 315 was always true

316 self._api = storage._api 

317 self._session = storage._session 

318 else: 

319 self._api = None 

320 self._session = None 

321 

322 super().__init__(version, project, timestamp) 

323 LazyLoadableMixin.__init__(self, lazy) 

324 

325 self._files = [file for file in files] if files is not None else [] 

326 self._requirements = {k: v for k, v in requirements} if requirements is not None else {None: []} 

327 

328 def __lazy_loader__(self, targetLevel: LazyLoaderState) -> None: 

329 """ 

330 Download the release's details and post-process them, as far as the target state demands. 

331 

332 :param targetLevel: Optional, state the release needs to be loaded to. 

333 """ 

334 if targetLevel >= LazyLoaderState.PartiallyLoaded: 334 ↛ 336line 334 didn't jump to line 336 because the condition on line 334 was always true

335 self.DownloadDetails() 

336 if targetLevel >= LazyLoaderState.PostProcessed: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 self.PostProcess() 

338 

339 @lazy(LazyLoaderState.PostProcessed) 

340 @PackageVersion.DependsOn.getter 

341 def DependsOn(self) -> dict[Package, dict[SemanticVersion, PackageVersion]]: 

342 """ 

343 Read-only property to access the packages this release depends on. 

344 

345 :returns: Dictionary of packages and their versions this release depends on. 

346 """ 

347 return super().DependsOn 

348 

349 @readonly 

350 def Project(self) -> Project: 

351 """ 

352 Read-only property to access the project this release belongs to (:attr:`_package`). 

353 

354 :returns: The project this release belongs to. 

355 """ 

356 return self._package 

357 

358 @lazy(LazyLoaderState.PartiallyLoaded) 

359 @readonly 

360 def Files(self) -> list[Distribution]: 

361 """ 

362 Read-only property to access the distributions published for this release (:attr:`_files`). 

363 

364 :returns: List of distributions. 

365 """ 

366 return self._files 

367 

368 @lazy(LazyLoaderState.PartiallyLoaded) 

369 @readonly 

370 def Requirements(self) -> dict[str, list[Requirement]]: 

371 """ 

372 Read-only property to access the release's requirements, grouped by extra (:attr:`_requirements`). 

373 

374 :returns: Dictionary of extras and their requirements. Requirements without an extra are stored under ``None``. 

375 """ 

376 return self._requirements 

377 

378 def _GetPyPIEndpoint(self) -> str: 

379 """ 

380 Return the API endpoint describing this release. 

381 

382 :returns: The endpoint's path, relative to the index's API URL. 

383 """ 

384 return f"{self._package._name.lower()}/{self._version}/json" 

385 

386 def DownloadDetails(self) -> None: 

387 """ 

388 Download this release's details from the package index and load the projects it requires. 

389 

390 :raises NoSessionAvailableException: If the release wasn't created by a package index, so it has no session. |br| 

391 A session is opened by the package index and handed to the objects it 

392 creates. 

393 :raises ReleaseNotFoundException: If the index doesn't know this release. 

394 """ 

395 if self._session is None: 395 ↛ 396line 395 didn't jump to line 396 because the condition on line 395 was never true

396 ex = NoSessionAvailableException(f"No session available to download release '{self._version}' of package '{self._package._name}'.") 

397 ex.add_note(f"A session is opened by the package index and handed to the objects it creates.") 

398 raise ex 

399 

400 response = self._session.get(url=f"{self._api}{self._GetPyPIEndpoint()}") 

401 try: 

402 response.raise_for_status() 

403 except HTTPError as ex: 

404 if ex.response is not None and ex.response.status_code == 404: 

405 raise ReleaseNotFoundException(f"Release '{self._version}' of package '{self._package._name}' not found.") from ex 

406 

407 self.UpdateDetailsFromPyPIJSON(response.json()) 

408 

409 index: PythonPackageIndex = self._package._storage 

410 for requirement in self._requirements[None]: 

411 packageName = requirement.name 

412 index.DownloadProject(packageName, True) 

413 

414 def UpdateDetailsFromPyPIJSON(self, json) -> None: 

415 """ 

416 Fill this release from the JSON document the package index returned. 

417 

418 The requirements are sorted into the extras they belong to; requirements without a marker are collected under 

419 ``None``. A requirement naming an unknown extra is reported as a :class:`BrokenRequirementWarning`. 

420 

421 :param json: The parsed JSON document describing this release. 

422 """ 

423 infoNode = json["info"] 

424 if (extras := infoNode["provides_extra"]) is not None: 

425 self._requirements = {extra: [] for extra in extras} 

426 self._requirements[None] = [] 

427 

428 if (requirements := infoNode["requires_dist"]) is not None: 428 ↛ 454line 428 didn't jump to line 454 because the condition on line 428 was always true

429 brokenRequirements = [] 

430 for requirement in requirements: 

431 req = Requirement(requirement) 

432 

433 # Handle requirements without an extra marker 

434 if req.marker is None: 

435 self._requirements[None].append(req) 

436 continue 

437 

438 for extra in self._requirements.keys(): 

439 if extra is not None and req.marker.evaluate({"extra": extra}): 

440 self._requirements[extra].append(req) 

441 break 

442 else: 

443 brokenRequirements.append(req) 

444 

445 if len(brokenRequirements) > 0: 

446 WarningCollector.Raise( 

447 BrokenRequirementWarning(f"Package '{self._package._name}' has {len(brokenRequirements)} requirement(s) whose marker matches no declared extra."), 

448 notes=[f"Broken requirement: {req}" for req in brokenRequirements] 

449 ) 

450 # Preserving the broken requirements under the special index 0 makes 'Requirements' a dictionary of mixed 

451 # key types (str, None and int), which no consumer expects. 

452 # self._requirements[0] = brokenRequirements 

453 

454 self.__lazy_state__ = LazyLoaderState.FullyLoaded 

455 

456 def PostProcess(self) -> None: 

457 """ 

458 Resolve this release's requirements into dependencies on concrete releases. 

459 

460 Every required project is downloaded and the releases matching the requirement's specifier are attached as 

461 dependencies of this release. 

462 """ 

463 index: PythonPackageIndex = self._package._storage 

464 for requirement in self._requirements[None]: 

465 package = index.DownloadProject(requirement.name) 

466 

467 for release in package: 

468 if str(release._version) in requirement.specifier: 

469 self.AddDependencyToPackageVersion(release) 

470 

471 self.SortDependencies() 

472 self.__lazy_state__ = LazyLoaderState.PostProcessed 

473 

474 @lazy(LazyLoaderState.PartiallyLoaded) 

475 def __repr__(self) -> str: 

476 """ 

477 Return a detailed string representation of this release, loading its details if needed. 

478 

479 :returns: Package name, version and the number of distributions. 

480 """ 

481 return f"Release: {self._package._name}:{self._version} Files: {len(self._files)}" 

482 

483 def __str__(self) -> str: 

484 """ 

485 Return a string representation of this release. 

486 

487 :returns: The release's version number. 

488 """ 

489 return f"{self._version}" 

490 

491 

492@export 

493class Project(Package, LazyLoadableMixin): 

494 """ 

495 A project (package) on a Python package index, with its releases. 

496 

497 The list of releases and the project's details are fetched from the index on first use. 

498 """ 

499 _url: Nullable[URL] #: URL of the project's page on the package index. 

500 

501 _api: Nullable[URL] #: URL of the package index's API, used to load the project's details. 

502 _session: Nullable[Session] #: HTTP session reused for the API requests. 

503 

504 def __init__( 

505 self, 

506 name: str, 

507 url: Union[str, URL], 

508 releases: Nullable[Iterable[Release]] = None, 

509 index: Nullable[PythonPackageIndex] = None, 

510 lazy: LazyLoaderState = LazyLoaderState.Initialized 

511 ) -> None: 

512 """ 

513 Initialize a project on a package index. 

514 

515 The API endpoint and the HTTP session are taken from the index, so the project can fetch its own details. 

516 

517 :param name: Name of the project on the package index. 

518 :param url: URL of the project's page, as a string or a parsed URL. 

519 :param releases: Optional, releases of this project. 

520 :param index: Optional, package index this project is hosted on. 

521 :param lazy: Optional, state the project should be loaded to immediately. 

522 """ 

523 if index is not None: 523 ↛ 527line 523 didn't jump to line 527 because the condition on line 523 was always true

524 self._api = index._api 

525 self._session = index._session 

526 else: 

527 self._api = None 

528 self._session = None 

529 

530 super().__init__(name, storage=index) 

531 LazyLoadableMixin.__init__(self, lazy) 

532 

533 # if isinstance(url, str): 

534 # url = URL.Parse(url) 

535 # elif not isinstance(url, URL): 

536 # ex = TypeError("Parameter 'url' is not of type 'URL'.") 

537 # ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.") 

538 # raise ex 

539 # 

540 # self._url = url 

541 # self._releases = {release.Version: release for release in sorted(releases, key=lambda r: r.Version)} if releases is not None else {} 

542 

543 def __lazy_loader__(self, targetLevel: LazyLoaderState) -> None: 

544 """ 

545 Download the project's details and its releases' details, as far as the target state demands. 

546 

547 :param targetLevel: Optional, state the project needs to be loaded to. 

548 """ 

549 if targetLevel >= LazyLoaderState.PartiallyLoaded: 549 ↛ 551line 549 didn't jump to line 551 because the condition on line 549 was always true

550 self.DownloadDetails() 

551 if targetLevel >= LazyLoaderState.PostProcessed: 551 ↛ 552line 551 didn't jump to line 552 because the condition on line 551 was never true

552 self.DownloadReleaseDetails() 

553 

554 @readonly 

555 def PackageIndex(self) -> PythonPackageIndex: 

556 """ 

557 Read-only property to access the package index this project was read from (:attr:`_storage`). 

558 

559 :returns: The package index this project belongs to. 

560 """ 

561 return self._storage 

562 

563 @lazy(LazyLoaderState.PartiallyLoaded) 

564 @readonly 

565 def URL(self) -> URL: 

566 """ 

567 Read-only property to access the project's URL in the package index (:attr:`_url`). 

568 

569 :returns: URL of the project. 

570 """ 

571 return self._url 

572 

573 @lazy(LazyLoaderState.PartiallyLoaded) 

574 @readonly 

575 def Releases(self) -> dict[PythonVersion, Release]: 

576 """ 

577 Read-only property to access all known releases of this project (:attr:`_versions`). 

578 

579 :returns: Dictionary of versions and their releases. 

580 """ 

581 return self._versions 

582 

583 @lazy(LazyLoaderState.PartiallyLoaded) 

584 @readonly 

585 def ReleaseCount(self) -> int: 

586 """ 

587 Read-only property to return the number of known releases. 

588 

589 :returns: Number of releases. 

590 """ 

591 return len(self._versions) 

592 

593 @lazy(LazyLoaderState.PartiallyLoaded) 

594 @readonly 

595 def LatestRelease(self) -> Release: 

596 """ 

597 Read-only property to return the most recent release of this project. 

598 

599 :returns: The latest release. 

600 """ 

601 return firstValue(self._versions) 

602 

603 def _GetPyPIEndpoint(self) -> str: 

604 """ 

605 Return the API endpoint describing this project. 

606 

607 :returns: The endpoint's path, relative to the index's API URL. 

608 """ 

609 return f"{self._name.lower()}/json" 

610 

611 def DownloadDetails(self) -> None: 

612 """ 

613 Download this project's details and its list of releases from the package index. 

614 

615 :raises NoSessionAvailableException: If the project wasn't created by a package index, so it has no session. |br| 

616 A session is opened by the package index and handed to the objects it 

617 creates. 

618 :raises ProjectNotFoundException: If the index doesn't know this project. 

619 """ 

620 if self._session is None: 620 ↛ 621line 620 didn't jump to line 621 because the condition on line 620 was never true

621 ex = NoSessionAvailableException(f"No session available to download details of package '{self._name}'.") 

622 ex.add_note(f"A session is opened by the package index and handed to the objects it creates.") 

623 raise ex 

624 

625 response = self._session.get(url=f"{self._api}{self._GetPyPIEndpoint()}") 

626 try: 

627 response.raise_for_status() 

628 except HTTPError as ex: 

629 if ex.response is not None and ex.response.status_code == 404: 

630 raise ProjectNotFoundException(f"Package '{self._name}' not found.") from ex 

631 

632 self.UpdateDetailsFromPyPIJSON(response.json()) 

633 

634 def UpdateDetailsFromPyPIJSON(self, json) -> None: 

635 """ 

636 Fill this project from the JSON document the package index returned. 

637 

638 Releases without a distribution are skipped, and a version the parser doesn't understand is reported as a 

639 warning rather than failing the whole project. 

640 

641 :param json: The parsed JSON document describing this project. 

642 """ 

643 infoNode = json["info"] 

644 releasesNode = json["releases"] 

645 

646 # Update project/package URL 

647 self._url = URL.Parse(infoNode["project_url"]) 

648 

649 # Convert key to Version number, skip empty releases 

650 convertedReleasesNode = {} 

651 for k, v in releasesNode.items(): 

652 if len(v) == 0: 652 ↛ 653line 652 didn't jump to line 653 because the condition on line 652 was never true

653 continue 

654 

655 try: 

656 version = PythonVersion.Parse(k) 

657 convertedReleasesNode[version] = v 

658 except ValueError as ex: 

659 print(f"Unsupported version format '{k}' - {ex}") 

660 

661 for version, releaseNode in sorted(convertedReleasesNode.items(), key=lambda t: t[0]): 

662 if Parts.Postfix in version._parts: 662 ↛ 663line 662 didn't jump to line 663 because the condition on line 662 was never true

663 pass 

664 

665 files = [Distribution(file["filename"], file["url"], datetime.fromisoformat(file["upload_time_iso_8601"]), ) for 

666 file in releaseNode] 

667 lazy = LazyLoaderState.PartiallyLoaded if LazyLoaderState.PartiallyLoaded <= self.__lazy_state__ <= LazyLoaderState.FullyLoaded else LazyLoaderState.Initialized 

668 Release( 

669 version, 

670 files[0]._uploadTime, 

671 files, 

672 project=self, 

673 lazy=lazy 

674 ) 

675 

676 self.SortVersions() 

677 self.__lazy_state__ = LazyLoaderState.FullyLoaded 

678 

679 def DownloadReleaseDetails(self) -> None: 

680 """ 

681 Download the details of every release of this project, in parallel. 

682 

683 The requests run in one :mod:`asyncio` event loop over a shared session, because a project can easily have 

684 hundreds of releases. 

685 """ 

686 async def ParallelDownloadReleaseDetails(): 

687 """ 

688 Nested coroutine downloading the details of every release over one shared session. 

689 """ 

690 async def routine(session, release: Release): 

691 """ 

692 Nested coroutine downloading the details of a single release. 

693 

694 :param session: The HTTP session shared by all requests of this download. 

695 :param release: The release to download the details for. 

696 """ 

697 if Parts.Postfix in release._version._parts: 

698 pass 

699 

700 async with session.get(self._GetPyPIEndpoint()) as response: 

701 json = await response.json() 

702 response.raise_for_status() 

703 

704 release.UpdateDetailsFromPyPIJSON(json) 

705 

706 async with ClientSession(base_url=str(self._api), headers={"accept": "application/json"}) as session: 

707 tasks = [] 

708 for release in self._versions.values(): # type: Release 

709 tasks.append(routine(session, release)) 

710 

711 results = await asyncio_gather(*tasks, return_exceptions=True) 

712 delList = [] 

713 for release, result in zip(self.Releases.values(), results): 

714 if isinstance(result, Exception): 

715 delList.append((release, result)) 

716 

717 for release, ex in delList: 

718 WarningCollector.Raise( 

719 ReleaseDetailsWarning(f"Dropping release '{release.Version}' of package '{release.Project._name}': details couldn't be downloaded."), 

720 ex 

721 ) 

722 del self.Releases[release.Version] 

723 

724 asyncio_run(ParallelDownloadReleaseDetails()) 

725 self.__lazy_state__ = LazyLoaderState.PostProcessed 

726 

727 def __repr__(self) -> str: 

728 """ 

729 Return a detailed string representation of this project. 

730 

731 :returns: The project's name and its latest release's version. 

732 """ 

733 return f"Project: {self._name} latest: {self.LatestRelease._version}" 

734 

735 def __str__(self) -> str: 

736 """ 

737 Return a string representation of this project. 

738 

739 :returns: The project's name. 

740 """ 

741 return f"{self._name}" 

742 

743 

744@export 

745class PythonPackageIndex(PackageStorage): 

746 """ 

747 A Python package index like PyPI, addressed through its JSON API. 

748 

749 It is the entry point of the dependency graph: projects are looked up here, and every request to the index reuses 

750 the same HTTP session. 

751 """ 

752 _url: URL #: URL of the package index's website. 

753 _api: URL #: URL of the package index's API. 

754 _session: Session #: HTTP session reused for every request to this index. 

755 

756 def __init__(self, name: str, url: Union[str, URL], api: Union[str, URL], graph: PackageDependencyGraph) -> None: 

757 """ 

758 Initialize a package index and open the HTTP session used for every request to it. 

759 

760 :param name: Name of the package index. 

761 :param url: URL of the index's website, as a string or a parsed URL. 

762 :param api: URL of the index's JSON API, as a string or a parsed URL. 

763 :param graph: Dependency graph this index belongs to. 

764 :raises TypeError: If parameter 'url' is neither a string nor a :class:`~pyTooling.GenericPath.URL.URL`. 

765 :raises TypeError: If parameter 'api' is neither a string nor a :class:`~pyTooling.GenericPath.URL.URL`. 

766 """ 

767 super().__init__(name, graph) 

768 

769 if isinstance(url, str): 769 ↛ 771line 769 didn't jump to line 771 because the condition on line 769 was always true

770 url = URL.Parse(url) 

771 elif not isinstance(url, URL): 

772 ex = TypeError("Parameter 'url' is not of type 'URL'.") 

773 ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.") 

774 raise ex 

775 

776 self._url = url 

777 

778 if isinstance(api, str): 778 ↛ 780line 778 didn't jump to line 780 because the condition on line 778 was always true

779 api = URL.Parse(api) 

780 elif not isinstance(api, URL): 

781 ex = TypeError("Parameter 'api' is not of type 'URL'.") 

782 ex.add_note(f"Got type '{getFullyQualifiedName(api)}'.") 

783 raise ex 

784 

785 self._api = api 

786 

787 self._session = Session() 

788 self._session.headers["accept"] = "application/json" 

789 

790 @readonly 

791 def URL(self) -> URL: 

792 """ 

793 Read-only property to access the package index' base URL (:attr:`_url`). 

794 

795 :returns: Base URL of the package index. 

796 """ 

797 return self._url 

798 

799 @readonly 

800 def API(self) -> URL: 

801 """ 

802 Read-only property to access the package index' API URL (:attr:`_api`). 

803 

804 :returns: API URL of the package index. 

805 """ 

806 return self._api 

807 

808 @readonly 

809 def Projects(self) -> dict[str, Project]: 

810 """ 

811 Read-only property to access all projects known to this package index (:attr:`_packages`). 

812 

813 :returns: Dictionary of project names and projects. 

814 """ 

815 return self._packages 

816 

817 @readonly 

818 def ProjectCount(self) -> int: 

819 """ 

820 Read-only property to return the number of known projects. 

821 

822 :returns: Number of projects. 

823 """ 

824 return len(self._packages) 

825 

826 def _GetPyPIEndpoint(self, projectName: str) -> str: 

827 """ 

828 Return the API endpoint describing a project. 

829 

830 :param projectName: Name of the project on the package index. 

831 :returns: The endpoint's URL. 

832 """ 

833 return f"{self._api}{projectName.lower()}/json" 

834 

835 def DownloadProject(self, projectName: str, lazy: LazyLoaderState = LazyLoaderState.PartiallyLoaded) -> Project: 

836 """ 

837 Look up a project on this package index. 

838 

839 :param projectName: Name of the project on the package index. 

840 :param lazy: Optional, state the project should be loaded to immediately. 

841 :returns: The project, loaded as far as ``lazy`` demands. 

842 """ 

843 project = Project(projectName, "", index=self, lazy=lazy) 

844 

845 return project 

846 

847 def __repr__(self) -> str: 

848 """ 

849 Return a detailed string representation of this package index. 

850 

851 :returns: The index's name. 

852 """ 

853 return f"{self._name}" 

854 

855 def __str__(self) -> str: 

856 """ 

857 Return a string representation of this package index. 

858 

859 :returns: The index's name. 

860 """ 

861 return f"{self._name}" 

862 

863 

864@export 

865class PythonPackageDependencyGraph(PackageDependencyGraph): 

866 """ 

867 A dependency graph of Python packages, whose vertices are projects and whose edges are requirements. 

868 """ 

869 

870 def __init__(self, name: str) -> None: 

871 """ 

872 Initialize an empty dependency graph of Python packages. 

873 

874 :param name: Name of the dependency graph. 

875 """ 

876 super().__init__(name)