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
« 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.
34.. hint::
36 See :ref:`high-level help <DEPENDENCIES>` for explanations and usage examples.
37"""
38from __future__ import annotations
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
47from pyTooling.Exceptions import MissingDependencyException
49try:
50 from aiohttp import ClientSession
51except ImportError as ex: # pragma: no cover
52 raise MissingDependencyException(dependency="aiohttp", extra="pypi") from ex
54try:
55 from packaging.requirements import Requirement
56except ImportError as ex: # pragma: no cover
57 raise MissingDependencyException(dependency="packaging", extra="pypi") from ex
59try:
60 from requests import Session, HTTPError
61except ImportError as ex: # pragma: no cover
62 raise MissingDependencyException(dependency="requests", extra="pypi") from ex
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
75@export
76class LazyLoaderState(IntEnum):
77 """
78 Loading states of a lazy-loadable object, in the order they are reached.
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.
90@export
91class lazy:
92 """
93 Unified decorator that supports:
94 1. @lazy(state) def method()
95 2. @lazy(state) @property def prop()
96 """
98 def __init__(self, _requiredState: LazyLoaderState = LazyLoaderState.PartiallyLoaded):
99 """
100 Initialize the decorator with the loading state its member needs.
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
107 def __call__(self, wrapped):
108 """
109 Apply the decorator to a method or property.
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)
120 return self
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.
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
133 # 1. Thread-safe state check
134 with obj.__lazy_lock__:
135 if obj.__lazy_state__ < self._requiredState:
136 obj.__lazy_loader__(self._requiredState)
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)
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.
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)
155 return wrapper
158@export
159class LazyLoadableMixin(metaclass=ExtendedType, mixin=True):
160 """
161 Mixin-class for objects whose details are fetched on first use.
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.
171 def __init__(self, targetLevel: LazyLoaderState = LazyLoaderState.Initialized) -> None:
172 """
173 Initialize the lazy-loading state of an object.
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()
180 if targetLevel > self.__lazy_state__:
181 with self.__lazy_lock__:
182 self.__lazy_loader__(targetLevel)
184 @abstractmethod
185 def __lazy_loader__(self, targetLevel: LazyLoaderState) -> None:
186 """
187 Load the object's details up to the given state.
189 :param targetLevel: Optional, state the object needs to be loaded to.
190 """
191 pass
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.
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.
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
217 self._filename = filename
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
226 self._url = url
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
233 self._uploadTime = uploadTime
235 @readonly
236 def Filename(self) -> str:
237 """
238 Read-only property to access the distribution's filename (:attr:`_filename`).
240 :returns: Filename of the distribution.
241 """
242 return self._filename
244 @readonly
245 def URL(self) -> URL:
246 """
247 Read-only property to access the URL this distribution can be downloaded from (:attr:`_url`).
249 :returns: Download URL of the distribution.
250 """
251 return self._url
253 @readonly
254 def UploadTime(self) -> datetime:
255 """
256 Read-only property to access the time this distribution was uploaded (:attr:`_uploadTime`).
258 :returns: Upload time of the distribution.
259 """
260 return self._uploadTime
262 def __repr__(self) -> str:
263 """
264 Return a detailed string representation of this distribution.
266 :returns: The distribution's filename, prefixed by its kind.
267 """
268 return f"Distribution: {self._filename}"
270 def __str__(self) -> str:
271 """
272 Return a string representation of this distribution.
274 :returns: The distribution's filename.
275 """
276 return f"{self._filename}"
279@export
280class Release(PackageVersion, LazyLoadableMixin):
281 """
282 One released version of a project on a Python package index.
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.
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.
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.
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.
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
322 super().__init__(version, project, timestamp)
323 LazyLoadableMixin.__init__(self, lazy)
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: []}
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.
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()
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.
345 :returns: Dictionary of packages and their versions this release depends on.
346 """
347 return super().DependsOn
349 @readonly
350 def Project(self) -> Project:
351 """
352 Read-only property to access the project this release belongs to (:attr:`_package`).
354 :returns: The project this release belongs to.
355 """
356 return self._package
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`).
364 :returns: List of distributions.
365 """
366 return self._files
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`).
374 :returns: Dictionary of extras and their requirements. Requirements without an extra are stored under ``None``.
375 """
376 return self._requirements
378 def _GetPyPIEndpoint(self) -> str:
379 """
380 Return the API endpoint describing this release.
382 :returns: The endpoint's path, relative to the index's API URL.
383 """
384 return f"{self._package._name.lower()}/{self._version}/json"
386 def DownloadDetails(self) -> None:
387 """
388 Download this release's details from the package index and load the projects it requires.
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
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
407 self.UpdateDetailsFromPyPIJSON(response.json())
409 index: PythonPackageIndex = self._package._storage
410 for requirement in self._requirements[None]:
411 packageName = requirement.name
412 index.DownloadProject(packageName, True)
414 def UpdateDetailsFromPyPIJSON(self, json) -> None:
415 """
416 Fill this release from the JSON document the package index returned.
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`.
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] = []
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)
433 # Handle requirements without an extra marker
434 if req.marker is None:
435 self._requirements[None].append(req)
436 continue
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)
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
454 self.__lazy_state__ = LazyLoaderState.FullyLoaded
456 def PostProcess(self) -> None:
457 """
458 Resolve this release's requirements into dependencies on concrete releases.
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)
467 for release in package:
468 if str(release._version) in requirement.specifier:
469 self.AddDependencyToPackageVersion(release)
471 self.SortDependencies()
472 self.__lazy_state__ = LazyLoaderState.PostProcessed
474 @lazy(LazyLoaderState.PartiallyLoaded)
475 def __repr__(self) -> str:
476 """
477 Return a detailed string representation of this release, loading its details if needed.
479 :returns: Package name, version and the number of distributions.
480 """
481 return f"Release: {self._package._name}:{self._version} Files: {len(self._files)}"
483 def __str__(self) -> str:
484 """
485 Return a string representation of this release.
487 :returns: The release's version number.
488 """
489 return f"{self._version}"
492@export
493class Project(Package, LazyLoadableMixin):
494 """
495 A project (package) on a Python package index, with its releases.
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.
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.
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.
515 The API endpoint and the HTTP session are taken from the index, so the project can fetch its own details.
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
530 super().__init__(name, storage=index)
531 LazyLoadableMixin.__init__(self, lazy)
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 {}
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.
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()
554 @readonly
555 def PackageIndex(self) -> PythonPackageIndex:
556 """
557 Read-only property to access the package index this project was read from (:attr:`_storage`).
559 :returns: The package index this project belongs to.
560 """
561 return self._storage
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`).
569 :returns: URL of the project.
570 """
571 return self._url
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`).
579 :returns: Dictionary of versions and their releases.
580 """
581 return self._versions
583 @lazy(LazyLoaderState.PartiallyLoaded)
584 @readonly
585 def ReleaseCount(self) -> int:
586 """
587 Read-only property to return the number of known releases.
589 :returns: Number of releases.
590 """
591 return len(self._versions)
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.
599 :returns: The latest release.
600 """
601 return firstValue(self._versions)
603 def _GetPyPIEndpoint(self) -> str:
604 """
605 Return the API endpoint describing this project.
607 :returns: The endpoint's path, relative to the index's API URL.
608 """
609 return f"{self._name.lower()}/json"
611 def DownloadDetails(self) -> None:
612 """
613 Download this project's details and its list of releases from the package index.
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
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
632 self.UpdateDetailsFromPyPIJSON(response.json())
634 def UpdateDetailsFromPyPIJSON(self, json) -> None:
635 """
636 Fill this project from the JSON document the package index returned.
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.
641 :param json: The parsed JSON document describing this project.
642 """
643 infoNode = json["info"]
644 releasesNode = json["releases"]
646 # Update project/package URL
647 self._url = URL.Parse(infoNode["project_url"])
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
655 try:
656 version = PythonVersion.Parse(k)
657 convertedReleasesNode[version] = v
658 except ValueError as ex:
659 print(f"Unsupported version format '{k}' - {ex}")
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
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 )
676 self.SortVersions()
677 self.__lazy_state__ = LazyLoaderState.FullyLoaded
679 def DownloadReleaseDetails(self) -> None:
680 """
681 Download the details of every release of this project, in parallel.
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.
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
700 async with session.get(self._GetPyPIEndpoint()) as response:
701 json = await response.json()
702 response.raise_for_status()
704 release.UpdateDetailsFromPyPIJSON(json)
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))
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))
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]
724 asyncio_run(ParallelDownloadReleaseDetails())
725 self.__lazy_state__ = LazyLoaderState.PostProcessed
727 def __repr__(self) -> str:
728 """
729 Return a detailed string representation of this project.
731 :returns: The project's name and its latest release's version.
732 """
733 return f"Project: {self._name} latest: {self.LatestRelease._version}"
735 def __str__(self) -> str:
736 """
737 Return a string representation of this project.
739 :returns: The project's name.
740 """
741 return f"{self._name}"
744@export
745class PythonPackageIndex(PackageStorage):
746 """
747 A Python package index like PyPI, addressed through its JSON API.
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.
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.
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)
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
776 self._url = url
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
785 self._api = api
787 self._session = Session()
788 self._session.headers["accept"] = "application/json"
790 @readonly
791 def URL(self) -> URL:
792 """
793 Read-only property to access the package index' base URL (:attr:`_url`).
795 :returns: Base URL of the package index.
796 """
797 return self._url
799 @readonly
800 def API(self) -> URL:
801 """
802 Read-only property to access the package index' API URL (:attr:`_api`).
804 :returns: API URL of the package index.
805 """
806 return self._api
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`).
813 :returns: Dictionary of project names and projects.
814 """
815 return self._packages
817 @readonly
818 def ProjectCount(self) -> int:
819 """
820 Read-only property to return the number of known projects.
822 :returns: Number of projects.
823 """
824 return len(self._packages)
826 def _GetPyPIEndpoint(self, projectName: str) -> str:
827 """
828 Return the API endpoint describing a project.
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"
835 def DownloadProject(self, projectName: str, lazy: LazyLoaderState = LazyLoaderState.PartiallyLoaded) -> Project:
836 """
837 Look up a project on this package index.
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)
845 return project
847 def __repr__(self) -> str:
848 """
849 Return a detailed string representation of this package index.
851 :returns: The index's name.
852 """
853 return f"{self._name}"
855 def __str__(self) -> str:
856 """
857 Return a string representation of this package index.
859 :returns: The index's name.
860 """
861 return f"{self._name}"
864@export
865class PythonPackageDependencyGraph(PackageDependencyGraph):
866 """
867 A dependency graph of Python packages, whose vertices are projects and whose edges are requirements.
868 """
870 def __init__(self, name: str) -> None:
871 """
872 Initialize an empty dependency graph of Python packages.
874 :param name: Name of the dependency graph.
875 """
876 super().__init__(name)