Coverage for pyTooling/Dependency/__init__.py: 88%
602 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ ___ _ __ ___ _ __ __| | ___ _ __ ___ _ _ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | | | |/ _ \ '_ \ / _ \ '_ \ / _` |/ _ \ '_ \ / __| | | | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| |_| | __/ |_) | __/ | | | (_| | __/ | | | (__| |_| | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \___| .__/ \___|_| |_|\__,_|\___|_| |_|\___|\__, | #
7# |_| |___/ |___/ |_| |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 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.
38.. seealso::
40 :mod:`pyTooling.Dependency.Python`
41 |rarr| The implementation for Python packages on a package index.
42 :mod:`pyTooling.Versioning`
43 |rarr| The version numbers a requirement is resolved against.
44 :mod:`pyTooling.Graph`
45 |rarr| The graph a dependency graph converts to (:meth:`~pyTooling.Dependency.PackageDependencyGraph.ToGraph`).
46"""
47from __future__ import annotations
49from datetime import datetime
50from pathlib import Path
51from typing import TYPE_CHECKING, Optional as Nullable, Iterable, Self, Iterator
53from pyTooling.Decorators import export, readonly
54from pyTooling.MetaClasses import ExtendedType
55from pyTooling.Exceptions import AlreadyInHierarchyError, ToolingException
56from pyTooling.Common import getFullyQualifiedName, firstKey, firstValue
57from pyTooling.GenericPath.URL import URL
58from pyTooling.Graph import Graph, Vertex
59from pyTooling.Licensing import LicenseExpression, BaseLicense, UnknownLicense
60from pyTooling.Versioning import SemanticVersion
61from pyTooling.Warning import CriticalWarning, Warning
63if TYPE_CHECKING: # pragma: no cover
64 from packaging.requirements import Requirement
65 from pyTooling.Dependency.Resolver import ResolvedRequirement
68@export
69class DependencyError(ToolingException):
70 """Base-exception of all exceptions raised by :mod:`pyTooling.Dependency`."""
73@export
74class NoSessionAvailableError(DependencyError):
75 """
76 The operation needs a session to the package index, but no session was opened.
78 A session is created by the package index and handed to the objects it creates.
79 """
81 _projectName: Nullable[str] #: Name of the project that was to be requested.
82 _version: Nullable[SemanticVersion] #: Version of the release that was to be requested.
84 def __init__(
85 self,
86 message: str,
87 /, *,
88 projectName: Nullable[str] = None,
89 version: Nullable[SemanticVersion] = None
90 ) -> None:
91 """
92 Initializes the exception with the project and, for a release, the version that was to be requested.
94 :param message: The exception's message.
95 :param projectName: Optional, the name of the project that was to be requested.
96 :param version: Optional, the version of the release that was to be requested; ``None`` for the project itself.
97 """
98 super().__init__(message)
99 self._projectName = projectName
100 self._version = version
102 @readonly
103 def ProjectName(self) -> Nullable[str]:
104 """
105 Read-only property to access the name of the project that was to be requested (:attr:`_projectName`).
107 :returns: The project's name, or ``None`` if it wasn't recorded.
108 """
109 return self._projectName
111 @readonly
112 def Version(self) -> Nullable[SemanticVersion]:
113 """
114 Read-only property to access the version of the release that was to be requested (:attr:`_version`).
116 :returns: The release's version, or ``None`` if it wasn't recorded.
117 """
118 return self._version
121@export
122class RequirementsFileNotFoundError(DependencyError, FileNotFoundError):
123 """
124 A requirements file doesn't exist, or a ``-r`` line references one that doesn't.
126 It is a :exc:`FileNotFoundError` as well (and so an :exc:`OSError`), because the file doesn't exist; catching
127 :exc:`~pyTooling.Dependency.DependencyError` still catches it.
128 """
130 _filePath: Nullable[Path] #: Path of the requirements file that doesn't exist.
132 def __init__(self, message: str, /, *, filePath: Nullable[Path] = None) -> None:
133 """
134 Initializes the exception with the path of the requirements file that doesn't exist.
136 :param message: The exception's message.
137 :param filePath: Optional, the path of the requirements file that doesn't exist.
138 """
139 super().__init__(message)
140 self._filePath = filePath
142 @readonly
143 def FilePath(self) -> Nullable[Path]:
144 """
145 Read-only property to access the path of the requirements file that doesn't exist (:attr:`_filePath`).
147 :returns: The path, or ``None`` if it wasn't recorded.
148 """
149 return self._filePath
152@export
153class CircularRequirementsFileError(DependencyError):
154 """
155 A ``-r`` line references a requirements file that is already being read further up the include chain.
157 A cycle isn't read once and ignored: a file including itself is a statement nobody wrote on purpose, and reading
158 it silently would hide it. The exception names the chain, so the ``-r`` line to delete is in the message. A file
159 two files reference, without one being read inside the other, is no cycle.
160 """
162 _filePath: Nullable[Path] #: Path of the requirements file that is already being read.
163 _referencedIn: Nullable[Path] #: Path of the file whose ``-r`` line references it again.
164 _lineNumber: Nullable[int] #: Number of that ``-r`` line.
166 def __init__(
167 self,
168 message: str,
169 /, *,
170 filePath: Nullable[Path] = None,
171 referencedIn: Nullable[Path] = None,
172 lineNumber: Nullable[int] = None
173 ) -> None:
174 """
175 Initializes the exception with the file read again and the ``-r`` line referencing it.
177 :param message: The exception's message.
178 :param filePath: Optional, the path of the requirements file that is already being read.
179 :param referencedIn: Optional, the path of the file whose ``-r`` line references it again.
180 :param lineNumber: Optional, the number of that ``-r`` line.
181 """
182 super().__init__(message)
183 self._filePath = filePath
184 self._referencedIn = referencedIn
185 self._lineNumber = lineNumber
187 @readonly
188 def FilePath(self) -> Nullable[Path]:
189 """
190 Read-only property to access the path of the requirements file that is already being read (:attr:`_filePath`).
192 :returns: The path, or ``None`` if it wasn't recorded.
193 """
194 return self._filePath
196 @readonly
197 def ReferencedIn(self) -> Nullable[Path]:
198 """
199 Read-only property to access the path of the file whose ``-r`` line references it again (:attr:`_referencedIn`).
201 :returns: The path, or ``None`` if it wasn't recorded.
202 """
203 return self._referencedIn
205 @readonly
206 def LineNumber(self) -> Nullable[int]:
207 """
208 Read-only property to access the number of that ``-r`` line (:attr:`_lineNumber`).
210 :returns: The line number, or ``None`` if it wasn't recorded.
211 """
212 return self._lineNumber
215@export
216class AlreadyInRequirementsTreeError(DependencyError, AlreadyInHierarchyError):
217 """A requirements file is given as an entry to another one, but a third file references it already."""
220@export
221class ProjectNotFoundError(DependencyError, LookupError):
222 """
223 The package index doesn't know a project of that name.
225 It is a :exc:`LookupError` as well, because the project is looked up by name; catching
226 :exc:`~pyTooling.Dependency.DependencyError` still catches it.
227 """
229 _projectName: Nullable[str] #: Name of the project the package index doesn't know.
231 def __init__(self, message: str, /, *, projectName: Nullable[str] = None) -> None:
232 """
233 Initializes the exception with the name of the project that wasn't found.
235 :param message: The exception's message.
236 :param projectName: Optional, the name of the project the package index doesn't know.
237 """
238 super().__init__(message)
239 self._projectName = projectName
241 @readonly
242 def ProjectName(self) -> Nullable[str]:
243 """
244 Read-only property to access the name of the project that wasn't found (:attr:`_projectName`).
246 :returns: The project's name, or ``None`` if it wasn't recorded.
247 """
248 return self._projectName
251@export
252class ReleaseNotFoundError(DependencyError, LookupError):
253 """
254 The project exists in the package index, but not in the requested version.
256 It is a :exc:`LookupError` as well, because the release is looked up by version; catching
257 :exc:`~pyTooling.Dependency.DependencyError` still catches it.
258 """
260 _projectName: Nullable[str] #: Name of the project the release was requested of.
261 _version: Nullable[SemanticVersion] #: Version of the release the package index doesn't know.
263 def __init__(
264 self,
265 message: str,
266 /,
267 *,
268 projectName: Nullable[str] = None,
269 version: Nullable[SemanticVersion] = None
270 ) -> None:
271 """
272 Initializes the exception with the project and the version of the release that wasn't found.
274 :param message: The exception's message.
275 :param projectName: Optional, the name of the project the release was requested of.
276 :param version: Optional, the version of the release the package index doesn't know.
277 """
278 super().__init__(message)
279 self._projectName = projectName
280 self._version = version
282 @readonly
283 def ProjectName(self) -> Nullable[str]:
284 """
285 Read-only property to access the name of the project the release was requested of (:attr:`_projectName`).
287 :returns: The project's name, or ``None`` if it wasn't recorded.
288 """
289 return self._projectName
291 @readonly
292 def Version(self) -> Nullable[SemanticVersion]:
293 """
294 Read-only property to access the version of the release that wasn't found (:attr:`_version`).
296 :returns: The release's version, or ``None`` if it wasn't recorded.
297 """
298 return self._version
301@export
302class PackageIndexError(DependencyError):
303 """The package index responded with an HTTP error status other than ``404 Not Found``."""
305 _projectName: str #: Name of the project requested from the package index.
306 _version: Nullable[SemanticVersion] #: Version of the release requested, or ``None`` for the project itself.
308 def __init__(
309 self,
310 message: str,
311 /,
312 *,
313 projectName: str,
314 version: Nullable[SemanticVersion] = None
315 ) -> None:
316 """
317 Initializes the exception with the project and, for a release, the version requested from the package index.
319 :param message: The exception's message.
320 :param projectName: The name of the project requested from the package index.
321 :param version: Optional, the version of the release requested; ``None`` for the project itself.
322 """
323 super().__init__(message)
324 self._projectName = projectName
325 self._version = version
327 @readonly
328 def ProjectName(self) -> str:
329 """
330 Read-only property to access the name of the project requested from the package index (:attr:`_projectName`).
332 :returns: The project's name.
333 """
334 return self._projectName
336 @readonly
337 def Version(self) -> Nullable[SemanticVersion]:
338 """
339 Read-only property to access the version of the release requested (:attr:`_version`).
341 :returns: The release's version, or ``None`` for a request of the project itself or if it wasn't recorded.
342 """
343 return self._version
346@export
347class ExtraNotFoundError(DependencyError, LookupError):
348 """
349 A release doesn't declare the extra whose requirements were asked for.
351 It is a :exc:`LookupError` as well, because the extra is looked up by name; catching
352 :exc:`~pyTooling.Dependency.DependencyError` still catches it.
353 """
355 _projectName: str #: Name of the project the release belongs to.
356 _version: SemanticVersion #: Version of the release.
357 _extra: str #: Name of the extra the release doesn't declare.
359 def __init__(
360 self,
361 message: str,
362 /, *,
363 projectName: str,
364 version: SemanticVersion,
365 extra: str
366 ) -> None:
367 """
368 Initializes the exception with the release and the extra it doesn't declare.
370 :param message: The exception's message.
371 :param projectName: The name of the project the release belongs to.
372 :param version: The version of the release.
373 :param extra: The name of the extra the release doesn't declare.
374 """
375 super().__init__(message)
376 self._projectName = projectName
377 self._version = version
378 self._extra = extra
380 @readonly
381 def ProjectName(self) -> str:
382 """
383 Read-only property to access the name of the project the release belongs to (:attr:`_projectName`).
385 :returns: The project's name.
386 """
387 return self._projectName
389 @readonly
390 def Version(self) -> SemanticVersion:
391 """
392 Read-only property to access the version of the release (:attr:`_version`).
394 :returns: The release's version.
395 """
396 return self._version
398 @readonly
399 def Extra(self) -> str:
400 """
401 Read-only property to access the name of the extra the release doesn't declare (:attr:`_extra`).
403 :returns: The extra's name.
404 """
405 return self._extra
408@export
409class BrokenRequirementWarning(CriticalWarning):
410 """
411 Base-class of the warnings about a stated requirement that can't be used as stated.
413 A line of a requirements file can't be parsed, or a requirement names an extra the selected release doesn't
414 declare. The requirement is left out, so a dependency the caller relies on is missing. Catch this class to handle
415 both; each derived class carries what it is about.
417 .. important::
419 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the
420 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised.
421 """
424@export
425class UnparsableRequirementWarning(BrokenRequirementWarning):
426 """
427 A line of a requirements file can't be parsed as a requirement, so it's left out of the file.
429 .. important::
431 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the
432 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised.
433 """
435 _line: Nullable[str] #: The line's text.
436 _filePath: Nullable[Path] #: Path of the requirements file.
437 _lineNumber: Nullable[int] #: Number of the line in the requirements file.
439 def __init__(
440 self,
441 message: str,
442 /, *,
443 line: Nullable[str] = None,
444 filePath: Nullable[Path] = None,
445 lineNumber: Nullable[int] = None
446 ) -> None:
447 """
448 Initializes the warning with the line that can't be parsed and where it is.
450 :param message: The warning's message.
451 :param line: Optional, the line's text.
452 :param filePath: Optional, the path of the requirements file.
453 :param lineNumber: Optional, the number of the line in the requirements file.
454 """
455 super().__init__(message)
456 self._line = line
457 self._filePath = filePath
458 self._lineNumber = lineNumber
460 @readonly
461 def Line(self) -> Nullable[str]:
462 """
463 Read-only property to access the line's text (:attr:`_line`).
465 :returns: The line, or ``None`` if it wasn't recorded.
466 """
467 return self._line
469 @readonly
470 def FilePath(self) -> Nullable[Path]:
471 """
472 Read-only property to access the path of the requirements file (:attr:`_filePath`).
474 :returns: The file's path, or ``None`` if it wasn't recorded.
475 """
476 return self._filePath
478 @readonly
479 def LineNumber(self) -> Nullable[int]:
480 """
481 Read-only property to access the number of the line in the requirements file (:attr:`_lineNumber`).
483 :returns: The line number, or ``None`` if it wasn't recorded.
484 """
485 return self._lineNumber
488@export
489class UndeclaredExtraWarning(BrokenRequirementWarning):
490 """
491 A requirement names an extra (``pyTooling[terminal]``) the selected release doesn't declare.
493 The extra isn't followed; the release's other requirements are.
495 .. important::
497 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the
498 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised.
499 """
501 _requirement: Nullable[ResolvedRequirement] #: The requirement naming the extra.
502 _extra: Nullable[str] #: The extra's name, as the requirement states it.
503 _release: Nullable[PackageVersion] #: The release selected for the requirement.
505 def __init__(
506 self,
507 message: str,
508 /, *,
509 requirement: Nullable[ResolvedRequirement] = None,
510 extra: Nullable[str] = None,
511 release: Nullable[PackageVersion] = None
512 ) -> None:
513 """
514 Initializes the warning with the requirement, the extra it names and the release not declaring it.
516 :param message: The warning's message.
517 :param requirement: Optional, the requirement naming the extra.
518 :param extra: Optional, the extra's name, as the requirement states it.
519 :param release: Optional, the release selected for the requirement.
520 """
521 super().__init__(message)
522 self._requirement = requirement
523 self._extra = extra
524 self._release = release
526 @readonly
527 def Requirement(self) -> Nullable[ResolvedRequirement]:
528 """
529 Read-only property to access the requirement naming the extra (:attr:`_requirement`).
531 :returns: The resolved requirement, or ``None`` if it wasn't recorded.
532 """
533 return self._requirement
535 @readonly
536 def Extra(self) -> Nullable[str]:
537 """
538 Read-only property to access the extra's name, as the requirement states it (:attr:`_extra`).
540 :returns: The extra's name, or ``None`` if it wasn't recorded.
541 """
542 return self._extra
544 @readonly
545 def Release(self) -> Nullable[PackageVersion]:
546 """
547 Read-only property to access the release selected for the requirement (:attr:`_release`).
549 :returns: The release, or ``None`` if it wasn't recorded.
550 """
551 return self._release
554@export
555class UndeclaredExtraMarkerWarning(Warning):
556 """
557 Requirements of a release have a marker naming an extra the release doesn't declare.
559 Such a requirement can't be assigned to an extra, so it's not reachable through
560 :attr:`Release.Requirements <pyTooling.Dependency.Python.Release.Requirements>`. It's a defect of the release's
561 metadata, not a dependency missing from the result: pip doesn't install such a requirement either, as it installs
562 the requirements of the extras a release declares only.
564 .. attention::
566 It's a non-critical warning: without a :class:`~pyTooling.Warning.WarningCollector` up the call-hierarchy, it's
567 dropped silently.
568 """
570 _release: Nullable[PackageVersion] #: The release stating the requirements.
571 _requirements: tuple[Requirement, ...] #: The requirements whose marker names an undeclared extra.
573 def __init__(
574 self,
575 message: str,
576 /, *,
577 release: Nullable[PackageVersion] = None,
578 requirements: Nullable[Iterable[Requirement]] = None
579 ) -> None:
580 """
581 Initializes the warning with the release and the requirements it can't assign to an extra.
583 :param message: The warning's message.
584 :param release: Optional, the release stating the requirements.
585 :param requirements: Optional, the requirements whose marker names an undeclared extra.
586 """
587 super().__init__(message)
588 self._release = release
589 self._requirements = () if requirements is None else tuple(requirements)
591 @readonly
592 def Release(self) -> Nullable[PackageVersion]:
593 """
594 Read-only property to access the release stating the requirements (:attr:`_release`).
596 :returns: The release, or ``None`` if it wasn't recorded.
597 """
598 return self._release
600 @readonly
601 def Requirements(self) -> tuple[Requirement, ...]:
602 """
603 Read-only property to access the requirements whose marker names an undeclared extra (:attr:`_requirements`).
605 :returns: Tuple of requirements; empty if they weren't recorded.
606 """
607 return self._requirements
610@export
611class UnknownLicenseWarning(Warning):
612 """
613 A package version's license couldn't be resolved from what its package index publishes.
615 The published expression is kept in :attr:`~PackageVersion.LicenseExpression` either way, so the warning names
616 what would have to be stated by hand.
618 .. attention::
620 It's a non-critical warning: without a :class:`~pyTooling.Warning.WarningCollector` up the call-hierarchy, it's
621 dropped silently.
622 """
624 _projectName: Nullable[str] #: Name of the release's project.
625 _version: Nullable[SemanticVersion] #: Version of the release.
627 def __init__(
628 self,
629 message: str,
630 /, *,
631 projectName: Nullable[str] = None,
632 version: Nullable[SemanticVersion] = None
633 ) -> None:
634 """
635 Initializes the warning with the release whose license couldn't be resolved.
637 :param message: The warning's message.
638 :param projectName: Optional, the name of the release's project.
639 :param version: Optional, the version of the release.
640 """
641 super().__init__(message)
642 self._projectName = projectName
643 self._version = version
645 @readonly
646 def ProjectName(self) -> Nullable[str]:
647 """
648 Read-only property to access the name of the release's project (:attr:`_projectName`).
650 :returns: The project's name, or ``None`` if it wasn't recorded.
651 """
652 return self._projectName
654 @readonly
655 def Version(self) -> Nullable[SemanticVersion]:
656 """
657 Read-only property to access the version of the release (:attr:`_version`).
659 :returns: The release's version, or ``None`` if it wasn't recorded.
660 """
661 return self._version
664@export
665class ReleaseDetailsWarning(CriticalWarning):
666 """
667 Downloading the details of a release failed, therefore the release was dropped from the project.
669 .. important::
671 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the
672 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised.
673 """
675 _projectName: Nullable[str] #: Name of the release's project.
676 _version: Nullable[SemanticVersion] #: Version of the release.
678 def __init__(
679 self,
680 message: str,
681 /, *,
682 projectName: Nullable[str] = None,
683 version: Nullable[SemanticVersion] = None
684 ) -> None:
685 """
686 Initializes the warning with the release that was dropped.
688 :param message: The warning's message.
689 :param projectName: Optional, the name of the release's project.
690 :param version: Optional, the version of the release.
691 """
692 super().__init__(message)
693 self._projectName = projectName
694 self._version = version
696 @readonly
697 def ProjectName(self) -> Nullable[str]:
698 """
699 Read-only property to access the name of the release's project (:attr:`_projectName`).
701 :returns: The project's name, or ``None`` if it wasn't recorded.
702 """
703 return self._projectName
705 @readonly
706 def Version(self) -> Nullable[SemanticVersion]:
707 """
708 Read-only property to access the version of the release (:attr:`_version`).
710 :returns: The release's version, or ``None`` if it wasn't recorded.
711 """
712 return self._version
715@export
716class UnresolvedRequirementWarning(Warning):
717 """
718 A requirement couldn't be resolved: the package index doesn't know its project, or can't describe the release
719 selected for it.
721 The requirement stays in the resolution, with the reason as its status.
723 .. attention::
725 It's a non-critical warning: without a :class:`~pyTooling.Warning.WarningCollector` up the call-hierarchy, it's
726 dropped silently.
727 """
729 _projectName: str #: Name of the required project.
730 _version: Nullable[SemanticVersion] #: Version of the selected release, or ``None`` for an unknown project.
732 def __init__(
733 self,
734 message: str,
735 /, *,
736 projectName: str,
737 version: Nullable[SemanticVersion] = None
738 ) -> None:
739 """
740 Initializes the warning with the required project and, if one was selected, the release.
742 :param message: The warning's message.
743 :param projectName: The name of the required project.
744 :param version: Optional, the version of the selected release; ``None`` for an unknown project.
745 """
746 super().__init__(message)
747 self._projectName = projectName
748 self._version = version
750 @readonly
751 def ProjectName(self) -> str:
752 """
753 Read-only property to access the name of the required project (:attr:`_projectName`).
755 :returns: The project's name.
756 """
757 return self._projectName
759 @readonly
760 def Version(self) -> Nullable[SemanticVersion]:
761 """
762 Read-only property to access the version of the release selected for the requirement (:attr:`_version`).
764 :returns: The release's version, or ``None`` for an unknown project or if it wasn't recorded.
765 """
766 return self._version
769@export
770class PackageIndexFailureWarning(CriticalWarning):
771 """
772 The package index failed to answer for a project or a release: it responded with a server error (``5xx``), or the
773 request failed on the network.
775 The requirement stays in the resolution, with the status
776 :attr:`~pyTooling.Dependency.Resolver.ResolutionStatus.IndexFailure`. The exception raised by the request is the
777 warning's cause.
779 .. important::
781 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the
782 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised.
783 """
785 _projectName: str #: Name of the project requested.
786 _version: Nullable[SemanticVersion] #: Version of the release requested, or ``None`` for the project itself.
787 _statusCode: Nullable[int] #: HTTP status code of the response, or ``None`` for a network failure.
789 def __init__(
790 self,
791 message: str,
792 /, *,
793 projectName: str,
794 version: Nullable[SemanticVersion] = None,
795 statusCode: Nullable[int]
796 ) -> None:
797 """
798 Initializes the warning with the project and, for a release, the version requested, and the response's status.
800 :param message: The warning's message.
801 :param projectName: The name of the project requested.
802 :param version: Optional, the version of the release requested; ``None`` for the project itself.
803 :param statusCode: The HTTP status code of the response; ``None`` for a network failure.
804 """
805 super().__init__(message)
806 self._projectName = projectName
807 self._version = version
808 self._statusCode = statusCode
810 @readonly
811 def ProjectName(self) -> str:
812 """
813 Read-only property to access the name of the project requested (:attr:`_projectName`).
815 :returns: The project's name.
816 """
817 return self._projectName
819 @readonly
820 def Version(self) -> Nullable[SemanticVersion]:
821 """
822 Read-only property to access the version of the release requested (:attr:`_version`).
824 :returns: The release's version, or ``None`` for a request of the project itself or if it wasn't recorded.
825 """
826 return self._version
828 @readonly
829 def StatusCode(self) -> Nullable[int]:
830 """
831 Read-only property to access the HTTP status code of the response (:attr:`_statusCode`).
833 :returns: The status code, or ``None`` for a network failure.
834 """
835 return self._statusCode
838@export
839class ResolutionTimeoutWarning(CriticalWarning):
840 """
841 The current :class:`~pyTooling.Timeout.Timeout` expired while a resolver resolved requirements.
843 The requirements it didn't get to stay in the resolution, with the status
844 :attr:`~pyTooling.Dependency.Resolver.ResolutionStatus.TimeoutExpired`; the resolution returned is partial.
846 .. important::
848 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the
849 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised.
850 """
852 _seconds: Nullable[float] #: The configured timeout in seconds.
853 _projectNames: Nullable[tuple[str, ...]] #: Names of the projects whose requirements weren't resolved.
855 def __init__(
856 self,
857 message: str,
858 /, *,
859 seconds: Nullable[float] = None,
860 projectNames: Nullable[Iterable[str]] = None
861 ) -> None:
862 """
863 Initializes the warning with the configured timeout and the projects whose requirements weren't resolved.
865 :param message: The warning's message.
866 :param seconds: Optional, the configured timeout in seconds.
867 :param projectNames: Optional, the names of the projects whose requirements weren't resolved.
868 """
869 super().__init__(message)
870 self._seconds = seconds
871 self._projectNames = None if projectNames is None else tuple(projectNames)
873 @readonly
874 def Seconds(self) -> Nullable[float]:
875 """
876 Read-only property to access the configured timeout in seconds (:attr:`_seconds`).
878 :returns: The timeout in seconds, or ``None`` if it wasn't recorded.
879 """
880 return self._seconds
882 @readonly
883 def ProjectNames(self) -> Nullable[tuple[str, ...]]:
884 """
885 Read-only property to access the names of the projects whose requirements weren't resolved (:attr:`_projectNames`).
887 :returns: The project names, or ``None`` if they weren't recorded.
888 """
889 return self._projectNames
892@export
893class UnsupportedVersionWarning(CriticalWarning):
894 """
895 A release's version number has a format the version parser doesn't support, therefore the release was skipped.
897 A package index lists every release a project ever published, also those numbered before :pep:`440`, e.g. pytz'
898 ``2004d``. A skipped release is missing from the project, so the warning names it.
900 .. important::
902 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the
903 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised.
904 """
906 _projectName: str #: Name of the project the skipped release belongs to.
907 _version: str #: Version number of the skipped release, as published by the package index.
909 def __init__(self, message: str, /, *, projectName: str, version: str) -> None:
910 """
911 Initializes the warning with the project and the version number that can't be parsed.
913 :param message: The warning's message.
914 :param projectName: Name of the project the skipped release belongs to.
915 :param version: Version number of the skipped release, as published by the package index.
916 """
917 super().__init__(message)
918 self._projectName = projectName
919 self._version = version
921 @readonly
922 def ProjectName(self) -> str:
923 """
924 Read-only property to access the name of the project the skipped release belongs to (:attr:`_projectName`).
926 :returns: The project's name.
927 """
928 return self._projectName
930 @readonly
931 def Version(self) -> str:
932 """
933 Read-only property to access the version number of the skipped release (:attr:`_version`).
935 :returns: The version number as published by the package index.
936 """
937 return self._version
940@export
941class PackageVersion(metaclass=ExtendedType, slots=True):
942 """
943 The package's version of a :class:`Package`.
945 A :class:`Package` has multiple available versions. A version can have multiple dependencies to other
946 :class:`PackageVersion`s.
947 """
949 _package: Package #: Reference to the corresponding package
950 _version: SemanticVersion #: :class:`SemanticVersion` of this version.
951 _releasedAt: Nullable[datetime] #: Time this package version was released.
952 _licenseExpression: LicenseExpression #: What was published about the license.
953 _licenseURL: Nullable[URL] #: URL of the license's text, if known.
954 _repositoryURL: Nullable[URL] #: URL of the source repository, if known.
955 _documentationURL: Nullable[URL] #: URL of the documentation, if known.
956 _issueTrackerURL: Nullable[URL] #: URL of the issue tracker, if known.
957 _projectURL: Nullable[URL] #: URL of the project's homepage.
958 _changelogURL: Nullable[URL] #: URL of the changelog, if known.
959 _dependsOn: dict[Package, dict[SemanticVersion, PackageVersion]] #: Versioned dependencies to other packages.
961 def __init__(self, version: SemanticVersion, package: Package, releasedAt: Nullable[datetime] = None) -> None:
962 """
963 Initializes a package version.
965 :param version: Semantic version of this package.
966 :param package: Package this version is associated to.
967 :param releasedAt: Optional, release date and time.
968 :raises TypeError: When parameter 'version' is not of type :class:`SemanticVersion`.
969 :raises TypeError: When parameter 'package' is not of type :class:`Package`.
970 :raises TypeError: When parameter 'releasedAt' is not of type :class:`~datetime.datetime`.
971 :raises ToolingException: When version already exists for the associated package.
972 """
973 if not isinstance(version, SemanticVersion): 973 ↛ 974line 973 didn't jump to line 974 because the condition on line 973 was never true
974 ex = TypeError("Parameter 'version' is not of type 'SemanticVersion'.")
975 ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.")
976 raise ex
977 elif version in package._versions: 977 ↛ 978line 977 didn't jump to line 978 because the condition on line 977 was never true
978 raise ToolingException(f"Version '{version}' is already registered in package '{package._name}'.")
980 self._version = version
981 package._versions[version] = self
983 if not isinstance(package, Package): 983 ↛ 984line 983 didn't jump to line 984 because the condition on line 983 was never true
984 ex = TypeError("Parameter 'package' is not of type 'Package'.")
985 ex.add_note(f"Got type '{getFullyQualifiedName(package)}'.")
986 raise ex
988 self._package = package
990 if releasedAt is not None and not isinstance(releasedAt, datetime): 990 ↛ 991line 990 didn't jump to line 991 because the condition on line 990 was never true
991 ex = TypeError("Parameter 'releasedAt' is not of type 'datetime'.")
992 ex.add_note(f"Got type '{getFullyQualifiedName(releasedAt)}'.")
993 raise ex
995 self._releasedAt = releasedAt
996 self._licenseExpression = UnknownLicense()
997 self._licenseURL = None
998 self._repositoryURL = None
999 self._documentationURL = None
1000 self._issueTrackerURL = None
1001 self._projectURL = None
1002 self._changelogURL = None
1003 self._dependsOn = {}
1005 @readonly
1006 def Package(self) -> Package:
1007 """
1008 Read-only property to access the associated package.
1010 :returns: Associated package.
1011 """
1012 return self._package
1014 @readonly
1015 def Version(self) -> SemanticVersion:
1016 """
1017 Read-only property to access the semantic version of a package.
1019 :returns: Semantic version of a package.
1020 """
1021 return self._version
1023 @readonly
1024 def ReleasedAt(self) -> Nullable[datetime]:
1025 """
1026 Read-only property to access the release date and time.
1028 :returns: Optional release date and time.
1029 """
1030 return self._releasedAt
1032 @readonly
1033 def Licenses(self) -> tuple[BaseLicense, ...]:
1034 """
1035 Read-only property to return the licenses named by :attr:`LicenseExpression`.
1037 **Every** license the version is published under, whether or not SPDX knows it: an
1038 :class:`~pyTooling.Licensing.SPDXLicense` for one on the SPDX License List, a
1039 :class:`~pyTooling.Licensing.LicenseReference` for a ``LicenseRef-<id>`` that isn't. Both are a
1040 :class:`~pyTooling.Licensing.BaseLicense` and both answer
1041 :attr:`~pyTooling.Licensing.BaseLicense.Identifier`, so a report doesn't have to branch:
1043 .. code-block:: python
1045 [term.Identifier for term in version.Licenses] # ['MIT', 'LicenseRef-Proprietary']
1047 A :class:`~pyTooling.Licensing.License` object exists only for the first kind, and
1048 :attr:`~pyTooling.Licensing.SPDXLicense.License` is where it is reached.
1050 ``NOASSERTION`` and ``NONE`` are reported here too, as an
1051 :class:`~pyTooling.Licensing.UnknownLicense` - the index *stated* something, and dropping it would leave this
1052 indistinguishable from a version whose license didn't resolve at all. Test for that class where the
1053 difference matters; :attr:`LicenseExpression` is ``None`` only when nothing resolved.
1055 This flattens the expression: ``Apache-2.0 AND MIT`` and ``Apache-2.0 OR BSD-2-Clause`` both give two
1056 licenses, although one requires both and the other offers a choice. Ask :attr:`LicenseExpression` when that
1057 difference matters. A license exception is not a license and is not returned.
1059 :returns: Licenses this version is published under, or an empty tuple if nothing resolved.
1060 """
1061 return tuple(
1062 node
1063 for node in self._licenseExpression.IterateExpression()
1064 if isinstance(node, BaseLicense)
1065 )
1067 @readonly
1068 def LicenseExpression(self) -> LicenseExpression:
1069 """
1070 Read-only property to access the license expression (:attr:`_licenseExpression`).
1072 The expression keeps what :attr:`Licenses` flattens away - which licenses are required together and which are
1073 a choice.
1075 **It is never** ``None``. A version whose license didn't resolve carries an
1076 :class:`~pyTooling.Licensing.UnknownLicense`, which is SPDX's own way of saying so, and that node keeps what
1077 was published. Test for that class rather than for ``None``.
1079 :returns: The license expression.
1080 """
1081 return self._licenseExpression
1083 @readonly
1084 def PublishedLicense(self) -> str:
1085 """
1086 Read-only property to access the license as it was published.
1088 This is the expression's :attr:`~pyTooling.Licensing.LicenseExpression.OriginalText`, which is the **only**
1089 place the published text is kept - a parsed expression records what
1090 :meth:`~pyTooling.Licensing.LicenseExpression.Parse` read, and a node built because nothing parsed records
1091 what was stated anyway. :meth:`~pyTooling.Licensing.LicenseExpression.__str__` is not a substitute - it
1092 re-renders canonically, so ``Apache-2.0 or MIT`` comes back as ``Apache-2.0 OR MIT``.
1094 :returns: The license as published, or an empty string if nothing was published.
1095 """
1096 return self._licenseExpression.OriginalText
1098 @readonly
1099 def LicenseURL(self) -> Nullable[URL]:
1100 """
1101 Read-only property to access the URL of this version's license text (:attr:`_licenseURL`).
1103 A package index has no field for it, so this is only known where it was stated by hand.
1105 :returns: URL of the license's text, or ``None`` if unknown.
1106 """
1107 return self._licenseURL
1109 @readonly
1110 def RepositoryURL(self) -> Nullable[URL]:
1111 """
1112 Read-only property to access the URL of the source repository (:attr:`_repositoryURL`).
1114 This is where the sources live, which is not where the package is *published* - a package index has its own
1115 page per package.
1117 A package index publishes these per release, and they move - a project migrating to another forge has one
1118 URL before the migration and another after it.
1120 :returns: URL of the source repository, or ``None`` if this release didn't name one.
1121 """
1122 return self._repositoryURL
1124 @readonly
1125 def DocumentationURL(self) -> Nullable[URL]:
1126 """
1127 Read-only property to access the URL of the documentation (:attr:`_documentationURL`).
1129 A package index publishes these per release, and they move - a project migrating to another forge has one
1130 URL before the migration and another after it.
1132 :returns: URL of the documentation, or ``None`` if this release didn't name one.
1133 """
1134 return self._documentationURL
1136 @readonly
1137 def IssueTrackerURL(self) -> Nullable[URL]:
1138 """
1139 Read-only property to access the URL of the issue tracker (:attr:`_issueTrackerURL`).
1141 A package index publishes these per release, and they move - a project migrating to another forge has one
1142 URL before the migration and another after it.
1144 :returns: URL of the issue tracker, or ``None`` if this release didn't name one.
1145 """
1146 return self._issueTrackerURL
1148 @readonly
1149 def ProjectURL(self) -> Nullable[URL]:
1150 """
1151 Read-only property to access the URL of the project's homepage (:attr:`_projectURL`).
1153 A package index publishes these per release, and they move - a project migrating to another forge has one
1154 URL before the migration and another after it.
1156 :returns: URL of the project's homepage, or ``None`` if this release didn't name one.
1157 """
1158 return self._projectURL
1160 @readonly
1161 def ChangelogURL(self) -> Nullable[URL]:
1162 """
1163 Read-only property to access the URL of the changelog (:attr:`_changelogURL`).
1165 A package index publishes these per release, and they move - a project migrating to another forge has one
1166 URL before the migration and another after it.
1168 :returns: URL of the changelog, or ``None`` if this release didn't name one.
1169 """
1170 return self._changelogURL
1172 @readonly
1173 def DependsOn(self) -> dict[Package, dict[SemanticVersion, PackageVersion]]:
1174 """
1175 Read-only property to access the dictionary of dictionaries referencing dependencies.
1177 The outer dictionary key groups dependencies by :class:`Package`. |br|
1178 The inner dictionary key accesses dependencies by :class:`~pyTooling.Versioning.SemanticVersion`.
1180 :returns: Dictionary of dependencies.
1181 """
1182 return self._dependsOn
1184 def AddDependencyToPackageVersion(self, packageVersion: PackageVersion) -> None:
1185 """
1186 Add a dependency from current package version to another package version.
1188 :param packageVersion: Dependency to be added.
1189 """
1190 if (package := packageVersion._package) in self._dependsOn:
1191 pack = self._dependsOn[package]
1192 if (version := packageVersion._version) in pack: 1192 ↛ 1193line 1192 didn't jump to line 1193 because the condition on line 1192 was never true
1193 pass
1194 else:
1195 pack[version] = packageVersion
1196 else:
1197 self._dependsOn[package] = {packageVersion._version: packageVersion}
1199 def AddDependencyToPackageVersions(self, packageVersions: Iterable[PackageVersion]) -> None:
1200 """
1201 Add multiple dependencies from current package version to a list of other package versions.
1203 :param packageVersions: Dependencies to be added.
1204 """
1205 # TODO: check for iterable
1207 for packageVersion in packageVersions:
1208 if (package := packageVersion._package) in self._dependsOn:
1209 pack = self._dependsOn[package]
1210 if (version := packageVersion._version) in pack: 1210 ↛ 1211line 1210 didn't jump to line 1211 because the condition on line 1210 was never true
1211 pass
1212 else:
1213 pack[version] = packageVersion
1214 else:
1215 self._dependsOn[package] = {packageVersion._version: packageVersion}
1217 def AddDependencyTo(
1218 self,
1219 package: str | Package,
1220 version: str | SemanticVersion | Iterable[str | SemanticVersion]
1221 ) -> None:
1222 """
1223 Add a dependency from current package version to another package version.
1225 :param package: :class:`Package` object or name of the package.
1226 :param version: :class:`~pyTooling.Versioning.SemanticVersion` object or version string or an iterable thereof.
1227 :raises TypeError: If parameter 'package' is not of type :class:`Package`.
1228 """
1229 if isinstance(package, str):
1230 package = self._package._storage._packages[package]
1231 elif not isinstance(package, Package): 1231 ↛ 1232line 1231 didn't jump to line 1232 because the condition on line 1231 was never true
1232 ex = TypeError("Parameter 'package' is not of type 'str' nor 'Package'.")
1233 ex.add_note(f"Got type '{getFullyQualifiedName(package)}'.")
1234 raise ex
1236 if isinstance(version, str):
1237 version = SemanticVersion.Parse(version)
1238 elif isinstance(version, Iterable):
1239 for v in version:
1240 if isinstance(v, str): 1240 ↛ 1242line 1240 didn't jump to line 1242 because the condition on line 1240 was always true
1241 v = SemanticVersion.Parse(v)
1242 elif not isinstance(v, SemanticVersion):
1243 ex = TypeError("Parameter 'version' contains an element, which is not of type 'str' nor 'SemanticVersion'.")
1244 ex.add_note(f"Got type '{getFullyQualifiedName(v)}'.")
1245 raise ex#
1247 packageVersion = package._versions[v]
1248 self.AddDependencyToPackageVersion(packageVersion)
1250 return
1251 elif not isinstance(version, SemanticVersion): 1251 ↛ 1252line 1251 didn't jump to line 1252 because the condition on line 1251 was never true
1252 ex = TypeError("Parameter 'version' is not of type 'str' nor 'SemanticVersion'.")
1253 ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.")
1254 raise ex
1256 packageVersion = package._versions[version]
1257 self.AddDependencyToPackageVersion(packageVersion)
1259 def SortDependencies(self) -> Self:
1260 """
1261 Sort versions of a package and dependencies by version, thus dependency resolution can work on pre-sorted lists and
1262 dictionaries.
1264 :returns: The instance itself (for method-chaining).
1265 """
1266 for package, versions in self._dependsOn.items():
1267 self._dependsOn[package] = {version: versions[version] for version in sorted(versions.keys(), reverse=True)}
1268 return self
1270 def SolveLatest(self) -> Iterable[PackageVersion]:
1271 """
1272 Solve the dependency problem, while using preferably latest versions.
1274 .. todo::
1276 Describe algorithm.
1278 :returns: A list of :class:`PackageVersion`s fulfilling the constraints of the dependency problem.
1279 :raises ToolingException: When there is no valid solution to the problem.
1280 """
1281 solution: dict[Package, PackageVersion] = {self._package: self}
1283 def _recursion(currentSolution: dict[Package, PackageVersion]) -> bool:
1284 """
1285 Nested function for recursion.
1287 It adds the latest matching version of every package the current solution requires, and recurses until no
1288 package is missing.
1290 :param currentSolution: The packages selected so far, by package.
1291 :returns: ``True``, if the solution is complete and consistent.
1292 """
1293 # 1. Identify all required packages based on current selection
1294 requiredPackages: set[Package] = set()
1295 for packageVersion in currentSolution.values():
1296 requiredPackages.update(packageVersion.DependsOn.keys())
1298 # 2. Identify which required packages are missing from the solution
1299 missingPackages = requiredPackages - currentSolution.keys()
1301 # Base Case: If no packages are missing, the graph is complete and valid
1302 if len(missingPackages) == 0:
1303 return True
1305 # 3. Pick the next package to resolve
1306 # (Heuristic: we just pick the first one, but could be optimized)
1307 targetPackage = next(iter(missingPackages))
1309 # 4. Determine valid candidates
1310 # The candidate version must satisfy the constraints of all parents currently in the solution
1311 allowedVersions: Nullable[set[SemanticVersion]] = None
1313 for parentPackageVersion in currentSolution.values():
1314 if targetPackage in parentPackageVersion.DependsOn:
1315 # Get the set of versions allowed by this specific parent
1316 # (Keys of the inner dict are SemanticVersion objects)
1317 parentConstraints = set(parentPackageVersion.DependsOn[targetPackage].keys())
1319 if allowedVersions is None:
1320 allowedVersions = parentConstraints
1321 else:
1322 # Intersect with existing constraints (must satisfy everyone)
1323 allowedVersions &= parentConstraints
1325 # If the intersection is empty, no version satisfies all parents -> backtrack
1326 if not allowedVersions:
1327 return False
1329 # 5. Try candidates (sorted descending to prioritize latest)
1330 # We convert the set to a list and sort it reverse
1331 for version_key in sorted(list(allowedVersions), reverse=True):
1332 candidate = targetPackage.Versions[version_key]
1334 # 6. Check compatibility (reverse dependencies)
1335 # Does the candidate depend on anything we have already selected?
1336 # If so, does the candidate accept the version we already picked?
1337 isCompatible = True
1338 for existingPackage, existingPackageVersion in currentSolution.items():
1339 if existingPackage in candidate.DependsOn:
1340 # If candidate relies on 'existingPackage', check if 'existingPackageVersion' is in the allowed list
1341 if existingPackageVersion._version not in candidate.DependsOn[existingPackage]:
1342 isCompatible = False
1343 break
1345 if isCompatible:
1346 # Tentatively add to solution
1347 currentSolution[targetPackage] = candidate
1349 # Recurse
1350 if _recursion(currentSolution):
1351 return True
1353 # If recursion failed, remove (backtrack) and try next version
1354 del currentSolution[targetPackage]
1356 # If we run out of versions for this package, this path is dead
1357 return False
1359 # Run the solver
1360 if _recursion(solution):
1361 return list(solution.values())
1362 else:
1363 raise ToolingException(f"Could not resolve dependencies for '{self}'.")
1365 def __len__(self) -> int:
1366 """
1367 Returns the number of dependencies.
1369 :returns: Number of dependencies.
1370 """
1371 return len(self._dependsOn)
1373 def __str__(self) -> str:
1374 """
1375 Return a string representation of this package version.
1377 :returns: The package's name and version.
1378 """
1379 return f"{self._package._name} - {self._version}"
1382@export
1383class Package(metaclass=ExtendedType, slots=True):
1384 """
1385 The package, which exists in multiple versions (:class:`PackageVersion`).
1386 """
1387 _storage: PackageStorage #: Reference to the package's storage.
1388 _name: str #: Name of the package.
1389 _versions: dict[SemanticVersion, PackageVersion] #: A dictionary of available versions for this package.
1391 def __init__(self, name: str, *, storage: PackageStorage) -> None:
1392 """
1393 Initializes a package.
1395 :param name: Name of the package.
1396 :param storage: The package's storage.
1397 :raises TypeError: If a parameter is not of the expected type.
1398 """
1399 if not isinstance(name, str): 1399 ↛ 1400line 1399 didn't jump to line 1400 because the condition on line 1399 was never true
1400 ex = TypeError("Parameter 'name' is not of type 'str'.")
1401 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
1402 raise ex
1404 self._name = name
1406 if not isinstance(storage, PackageStorage): 1406 ↛ 1407line 1406 didn't jump to line 1407 because the condition on line 1406 was never true
1407 ex = TypeError("Parameter 'storage' is not of type 'PackageStorage'.")
1408 ex.add_note(f"Got type '{getFullyQualifiedName(storage)}'.")
1409 raise ex
1411 self._storage = storage
1412 storage._packages[name] = self
1414 self._versions = {}
1416 @readonly
1417 def Storage(self) -> PackageStorage:
1418 """
1419 Read-only property to access the package's storage.
1421 :returns: Package storage.
1422 """
1423 return self._storage
1425 @readonly
1426 def Name(self) -> str:
1427 """
1428 Read-only property to access the package name.
1430 :returns: Name of the package.
1431 """
1432 return self._name
1434 @readonly
1435 def LatestVersion(self) -> Nullable[PackageVersion]:
1436 """
1437 Read-only property to return the most recent version of this package.
1439 Versions are held newest-first once :meth:`SortVersions` has run, so this is the first of them.
1441 :returns: The latest version, or ``None`` if the package has no version yet.
1442 """
1443 if len(self._versions) == 0:
1444 return None
1446 return firstValue(self._versions)
1448 @readonly
1449 def RepositoryURL(self) -> Nullable[URL]:
1450 """
1451 Read-only property to return the URL of the source repository, as the latest version states it.
1453 The URL belongs to a :class:`PackageVersion` because it moves over a project's life; this mirror answers what
1454 is true *now*, which is what a reader of the package usually wants.
1456 :returns: URL of the source repository of :attr:`LatestVersion`, or ``None`` if unknown or if
1457 the package has no version.
1458 """
1459 if (latest := self.LatestVersion) is None:
1460 return None
1462 return latest.RepositoryURL
1464 @readonly
1465 def DocumentationURL(self) -> Nullable[URL]:
1466 """
1467 Read-only property to return the URL of the documentation, as the latest version states it.
1469 The URL belongs to a :class:`PackageVersion` because it moves over a project's life; this mirror answers what
1470 is true *now*, which is what a reader of the package usually wants.
1472 :returns: URL of the documentation of :attr:`LatestVersion`, or ``None`` if unknown or the package has no version.
1473 """
1474 if (latest := self.LatestVersion) is None:
1475 return None
1477 return latest.DocumentationURL
1479 @readonly
1480 def IssueTrackerURL(self) -> Nullable[URL]:
1481 """
1482 Read-only property to return the URL of the issue tracker, as the latest version states it.
1484 The URL belongs to a :class:`PackageVersion` because it moves over a project's life; this mirror answers what
1485 is true *now*, which is what a reader of the package usually wants.
1487 :returns: URL of the issue tracker of :attr:`LatestVersion`, or ``None`` if unknown or the package has no version.
1488 """
1489 if (latest := self.LatestVersion) is None:
1490 return None
1492 return latest.IssueTrackerURL
1494 @readonly
1495 def ProjectURL(self) -> Nullable[URL]:
1496 """
1497 Read-only property to return the URL of the project's homepage, as the latest version states it.
1499 The URL belongs to a :class:`PackageVersion` because it moves over a project's life; this mirror answers what
1500 is true *now*, which is what a reader of the package usually wants.
1502 :returns: URL of the project's homepage of :attr:`LatestVersion`, or ``None`` if unknown or if
1503 the package has no version.
1504 """
1505 if (latest := self.LatestVersion) is None:
1506 return None
1508 return latest.ProjectURL
1510 @readonly
1511 def ChangelogURL(self) -> Nullable[URL]:
1512 """
1513 Read-only property to return the URL of the changelog, as the latest version states it.
1515 The URL belongs to a :class:`PackageVersion` because it moves over a project's life; this mirror answers what
1516 is true *now*, which is what a reader of the package usually wants.
1518 :returns: URL of the changelog of :attr:`LatestVersion`, or ``None`` if unknown or the package has no version.
1519 """
1520 if (latest := self.LatestVersion) is None:
1521 return None
1523 return latest.ChangelogURL
1525 @readonly
1526 def Versions(self) -> dict[SemanticVersion, PackageVersion]:
1527 """
1528 Read-only property to access the dictionary of available versions.
1530 :returns: Available version dictionary.
1531 """
1532 return self._versions
1534 @readonly
1535 def VersionCount(self) -> int:
1536 """
1537 Read-only property to return the number of versions this package has.
1539 :returns: Number of versions.
1540 """
1541 return len(self._versions)
1543 def SortVersions(self) -> None:
1544 """
1545 Sort versions within this package in reverse order (latest first).
1546 """
1547 self._versions = {k: self._versions[k].SortDependencies() for k in sorted(self._versions.keys(), reverse=True)}
1549 def __len__(self) -> int:
1550 """
1551 Returns the number of available versions.
1553 :returns: Number of versions.
1554 """
1555 return len(self._versions)
1557 def __iter__(self) -> Iterator[PackageVersion]:
1558 """
1559 Iterate the versions of this package.
1561 :returns: An iterator over all versions of this package.
1562 """
1563 return iter(self._versions.values())
1565 def __getitem__(self, version: str | SemanticVersion) -> PackageVersion:
1566 """
1567 Access a package version in the package by version string or semantic version.
1569 :param version: Version as string or instance.
1570 :returns: The package version.
1571 :raises KeyError: If version is not available for the package.
1572 :raises TypeError: If the given key is not of the expected type.
1573 """
1574 if isinstance(version, str): 1574 ↛ 1576line 1574 didn't jump to line 1576 because the condition on line 1574 was always true
1575 version = SemanticVersion.Parse(version)
1576 elif not isinstance(version, SemanticVersion):
1577 ex = TypeError("Parameter 'version' is neither a 'str' nor of type 'SemanticVersion'.")
1578 ex.add_note(f"Got type '{getFullyQualifiedName(version)}'.")
1579 raise ex
1581 return self._versions[version]
1583 def __str__(self) -> str:
1584 """
1585 Return a string representation of this package.
1587 :returns: The package's name and latest version.
1588 """
1589 if len(self._versions) == 0:
1590 return f"{self._name} (empty)"
1591 else:
1592 return f"{self._name} (latest: {firstKey(self._versions)})"
1595@export
1596class PackageStorage(metaclass=ExtendedType, slots=True):
1597 """
1598 A storage for packages.
1599 """
1600 _graph: PackageDependencyGraph #: Reference to the overall dependency graph data structure.
1601 _name: str #: Package dependency graph name
1602 _packages: dict[str, Package] #: Dictionary of known packages.
1604 def __init__(self, name: str, graph: PackageDependencyGraph) -> None:
1605 """
1606 Initializes the package storage.
1608 :param name: Name of the package storage.
1609 :param graph: PackageDependencyGraph instance (parent).
1610 :raises TypeError: If a parameter is not of the expected type.
1611 """
1612 if not isinstance(name, str): 1612 ↛ 1613line 1612 didn't jump to line 1613 because the condition on line 1612 was never true
1613 ex = TypeError("Parameter 'name' is not of type 'str'.")
1614 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
1615 raise ex
1617 self._name = name
1619 if not isinstance(graph, PackageDependencyGraph): 1619 ↛ 1620line 1619 didn't jump to line 1620 because the condition on line 1619 was never true
1620 ex = TypeError("Parameter 'graph' is not of type 'PackageDependencyGraph'.")
1621 ex.add_note(f"Got type '{getFullyQualifiedName(graph)}'.")
1622 raise ex
1624 self._graph = graph
1625 graph._storages[name] = self
1627 self._packages = {}
1629 @readonly
1630 def Graph(self) -> PackageDependencyGraph:
1631 """
1632 Read-only property to access the package dependency graph.
1634 :returns: Package dependency graph.
1635 """
1636 return self._graph
1638 @readonly
1639 def Name(self) -> str:
1640 """
1641 Read-only property to access the package dependency graph's name.
1643 :returns: Name of the package dependency graph.
1644 """
1645 return self._name
1647 @readonly
1648 def Packages(self) -> dict[str, Package]:
1649 """
1650 Read-only property to access the dictionary of known packages.
1652 :returns: Known packages dictionary.
1653 """
1654 return self._packages
1656 @readonly
1657 def PackageCount(self) -> int:
1658 """
1659 Read-only property to return the number of packages in this storage.
1661 :returns: Number of packages.
1662 """
1663 return len(self._packages)
1665 def CreatePackage(self, packageName: str) -> Package:
1666 """
1667 Create a new package in the package dependency graph.
1669 :param packageName: Name of the new package.
1670 :returns: New package's instance.
1671 """
1672 return Package(packageName, storage=self)
1674 def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]:
1675 """
1676 Create multiple new packages in the package dependency graph.
1678 :param packageNames: List of package names.
1679 :returns: List of new package instances.
1680 """
1681 return [Package(packageName, storage=self) for packageName in packageNames]
1683 def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion:
1684 """
1685 Create a new package and a package version in the package dependency graph.
1687 :param packageName: Name of the new package.
1688 :param version: Version string.
1689 :returns: New package version instance.
1690 """
1691 package = Package(packageName, storage=self)
1692 return PackageVersion(SemanticVersion.Parse(version), package)
1694 def CreatePackageVersions(self, packageName: str, versions: Iterable[str]) -> Iterable[PackageVersion]:
1695 """
1696 Create a new package and multiple package versions in the package dependency graph.
1698 :param packageName: Name of the new package.
1699 :param versions: List of version string.s
1700 :returns: List of new package version instances.
1701 """
1702 package = Package(packageName, storage=self)
1703 return [PackageVersion(SemanticVersion.Parse(version), package) for version in versions]
1705 def SortPackageVersions(self) -> None:
1706 """
1707 Sort versions within all known packages in reverse order (latest first).
1708 """
1709 for package in self._packages.values():
1710 package.SortVersions()
1712 def __len__(self) -> int:
1713 """
1714 Returns the number of known packages.
1716 :returns: Number of packages.
1717 """
1718 return len(self._packages)
1720 def __iter__(self) -> Iterator[Package]:
1721 """
1722 Iterate the packages in this storage.
1724 :returns: An iterator over all packages in this storage.
1725 """
1726 return iter(self._packages.values())
1728 def __getitem__(self, name: str) -> Package:
1729 """
1730 Access a known package in the package dependency graph by package name.
1732 :param name: Name of the package.
1733 :returns: The package.
1734 :raises KeyError: If package is not known within the package dependency graph.
1735 """
1736 return self._packages[name]
1738 def __str__(self) -> str:
1739 """
1740 Return a string representation of this graph.
1742 :returns: The graph's name and number of known packages.
1743 """
1744 if len(self._packages) == 0: 1744 ↛ 1747line 1744 didn't jump to line 1747 because the condition on line 1744 was always true
1745 return f"{self._name} (empty)"
1746 else:
1747 return f"{self._name} ({len(self._packages)})"
1750@export
1751class PackageDependencyGraph(metaclass=ExtendedType, slots=True):
1752 """
1753 A package dependency graph collecting all known packages.
1754 """
1755 _name: str #: Package dependency graph name
1756 _storages: dict[str, PackageStorage] #: Dictionary of known package storages.
1758 def __init__(self, name: str) -> None:
1759 """
1760 Initializes the package dependency graph.
1762 :param name: Name of the dependency graph.
1763 :raises TypeError: If a parameter is not of the expected type.
1764 """
1765 if not isinstance(name, str): 1765 ↛ 1766line 1765 didn't jump to line 1766 because the condition on line 1765 was never true
1766 ex = TypeError("Parameter 'name' is not of type 'str'.")
1767 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
1768 raise ex
1770 self._name = name
1772 self._storages = {}
1774 @readonly
1775 def Name(self) -> str:
1776 """
1777 Read-only property to access the package dependency graph's name.
1779 :returns: Name of the package dependency graph.
1780 """
1781 return self._name
1783 @readonly
1784 def Storages(self) -> dict[str, PackageStorage]:
1785 """
1786 Read-only property to access the dictionary of known package storages.
1788 :returns: Known package storage dictionary.
1789 """
1790 return self._storages
1792 # def CreatePackage(self, packageName: str) -> Package:
1793 # """
1794 # Create a new package in the package dependency graph.
1795 #
1796 # :param packageName: Name of the new package.
1797 # :returns: New package's instance.
1798 # """
1799 # return Package(packageName, storage=self)
1800 #
1801 # def CreatePackages(self, packageNames: Iterable[str]) -> Iterable[Package]:
1802 # """
1803 # Create multiple new packages in the package dependency graph.
1804 #
1805 # :param packageNames: List of package names.
1806 # :returns: List of new package instances.
1807 # """
1808 # return [Package(packageName, storage=self) for packageName in packageNames]
1809 #
1810 # def CreatePackageVersion(self, packageName: str, version: str) -> PackageVersion:
1811 # """
1812 # Create a new package and a package version in the package dependency graph.
1813 #
1814 # :param packageName: Name of the new package.
1815 # :param version: Version string.
1816 # :returns: New package version instance.
1817 # """
1818 # package = Package(packageName, storage=self)
1819 # return PackageVersion(SemanticVersion.Parse(version), package)
1820 #
1821 # def CreatePackageVersions(self, packageName: str, versions: Iterable[str]) -> Iterable[PackageVersion]:
1822 # """
1823 # Create a new package and multiple package versions in the package dependency graph.
1824 #
1825 # :param packageName: Name of the new package.
1826 # :param versions: List of version string.s
1827 # :returns: List of new package version instances.
1828 # """
1829 # package = Package(packageName, storage=self)
1830 # return [PackageVersion(SemanticVersion.Parse(version), package) for version in versions]
1832 def SortPackageVersions(self) -> None:
1833 """
1834 Sort versions within all known packages in reverse order (latest first).
1835 """
1836 for storage in self._storages.values():
1837 storage.SortPackageVersions()
1839 def ToGraph(self) -> Graph:
1840 """
1841 Convert the package dependency graph into a graph of package versions and their dependencies.
1843 Every package version of every storage becomes a :class:`~pyTooling.Graph.Vertex` of the graph, with the
1844 :class:`PackageVersion` as its :attr:`~pyTooling.Graph.Vertex.ID`, so
1845 :meth:`Graph.GetVertexByID <pyTooling.Graph.Graph.GetVertexByID>` finds a version's vertex. Its key-value pairs
1846 keep the version's typed objects:
1848 * ``license`` - the :class:`~pyTooling.Licensing.LicenseExpression`, an
1849 :class:`~pyTooling.Licensing.UnknownLicense` if it's unknown;
1850 * ``releasedAt`` - the release time as :class:`~datetime.datetime`;
1851 * ``licenseURL``, ``repositoryURL``, ``documentationURL``, ``issueTrackerURL``, ``projectURL`` and
1852 ``changelogURL`` - each a :class:`~pyTooling.GenericPath.URL.URL`.
1854 All but ``license`` are set only if the version knows them; for a Python package, after its details were loaded. The
1855 format is described in :ref:`DEPENDENCIES/Graph`.
1857 Every dependency becomes an :class:`~pyTooling.Graph.Edge` from the package version needing to the package
1858 version it needs, so an edge reads *needs*, and
1859 :meth:`Graph.IterateTopologically <pyTooling.Graph.BaseGraph.IterateTopologically>` yields the package versions
1860 dependencies first.
1862 :returns: The graph, named like the package dependency graph.
1863 """
1864 graph = Graph(name=self._name)
1866 vertices: dict[PackageVersion, Vertex] = {}
1867 for storage in self._storages.values():
1868 for package in storage._packages.values():
1869 for packageVersion in package._versions.values():
1870 keyValuePairs = {
1871 "license": packageVersion._licenseExpression
1872 }
1873 if packageVersion._releasedAt is not None:
1874 keyValuePairs["releasedAt"] = packageVersion._releasedAt
1876 for key, url in (
1877 ("licenseURL", packageVersion._licenseURL),
1878 ("repositoryURL", packageVersion._repositoryURL),
1879 ("documentationURL", packageVersion._documentationURL),
1880 ("issueTrackerURL", packageVersion._issueTrackerURL),
1881 ("projectURL", packageVersion._projectURL),
1882 ("changelogURL", packageVersion._changelogURL)
1883 ):
1884 if url is not None:
1885 keyValuePairs[key] = url
1887 vertices[packageVersion] = Vertex(vertexID=packageVersion, keyValuePairs=keyValuePairs, graph=graph)
1889 for packageVersion, vertex in vertices.items():
1890 for dependencies in packageVersion._dependsOn.values():
1891 for dependency in dependencies.values():
1892 vertex.EdgeToVertex(vertices[dependency])
1894 return graph
1896 def __len__(self) -> int:
1897 """
1898 Returns the number of known packages.
1900 :returns: Number of packages.
1901 """
1902 return len(self._storages)
1904 def __iter__(self) -> Iterator[PackageStorage]:
1905 """
1906 Iterate the storages in this dependency graph.
1908 :returns: An iterator over all storages in this dependency graph.
1909 """
1910 return iter(self._storages.values())
1912 def __getitem__(self, name: str) -> PackageStorage:
1913 """
1914 Access a known package storage in the package dependency graph by storage name.
1916 :param name: Name of the package storage.
1917 :returns: The package storage.
1918 :raises KeyError: If package storage is not known within the package dependency graph.
1919 """
1920 return self._storages[name]
1922 def __str__(self) -> str:
1923 """
1924 Return a string representation of this graph.
1926 :returns: The graph's name and number of known packages.
1927 """
1928 count = sum(len(storage) for storage in self._storages.values())
1929 if count == 0: 1929 ↛ 1932line 1929 didn't jump to line 1932 because the condition on line 1929 was always true
1930 return f"{self._name} (empty)"
1931 else:
1932 return f"{self._name} ({count})"