mirror of
https://github.com/pre-commit/pre-commit-hooks.git
synced 2026-04-06 03:56:54 +00:00
668 lines
21 KiB
Python
668 lines
21 KiB
Python
"""Exceptions used throughout package.
|
||
|
||
This module MUST NOT try to import from anything within `pip._internal` to
|
||
operate. This is expected to be importable from any/all files within the
|
||
subpackage and, thus, should not depend on them.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import configparser
|
||
import re
|
||
from itertools import chain
|
||
from itertools import groupby
|
||
from itertools import repeat
|
||
from typing import Dict
|
||
from typing import List
|
||
from typing import Optional
|
||
from typing import TYPE_CHECKING
|
||
from typing import Union
|
||
|
||
from pip._vendor.requests.models import Request
|
||
from pip._vendor.requests.models import Response
|
||
from pip._vendor.rich.console import Console
|
||
from pip._vendor.rich.console import ConsoleOptions
|
||
from pip._vendor.rich.console import RenderResult
|
||
from pip._vendor.rich.markup import escape
|
||
from pip._vendor.rich.text import Text
|
||
|
||
if TYPE_CHECKING:
|
||
from hashlib import _Hash
|
||
from typing import Literal
|
||
|
||
from pip._internal.metadata import BaseDistribution
|
||
from pip._internal.req.req_install import InstallRequirement
|
||
|
||
|
||
#
|
||
# Scaffolding
|
||
#
|
||
def _is_kebab_case(s: str) -> bool:
|
||
return re.match(r'^[a-z]+(-[a-z]+)*$', s) is not None
|
||
|
||
|
||
def _prefix_with_indent(
|
||
s: Text | str,
|
||
console: Console,
|
||
*,
|
||
prefix: str,
|
||
indent: str,
|
||
) -> Text:
|
||
if isinstance(s, Text):
|
||
text = s
|
||
else:
|
||
text = console.render_str(s)
|
||
|
||
return console.render_str(prefix, overflow='ignore') + console.render_str(
|
||
f'\n{indent}', overflow='ignore',
|
||
).join(text.split(allow_blank=True))
|
||
|
||
|
||
class PipError(Exception):
|
||
"""The base pip error."""
|
||
|
||
|
||
class DiagnosticPipError(PipError):
|
||
"""An error, that presents diagnostic information to the user.
|
||
|
||
This contains a bunch of logic, to enable pretty presentation of our error
|
||
messages. Each error gets a unique reference. Each error can also include
|
||
additional context, a hint and/or a note -- which are presented with the
|
||
main error message in a consistent style.
|
||
|
||
This is adapted from the error output styling in `sphinx-theme-builder`.
|
||
"""
|
||
|
||
reference: str
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
kind: Literal["error", "warning"] = 'error',
|
||
reference: str | None = None,
|
||
message: str | Text,
|
||
context: str | Text | None,
|
||
hint_stmt: str | Text | None,
|
||
note_stmt: str | Text | None = None,
|
||
link: str | None = None,
|
||
) -> None:
|
||
# Ensure a proper reference is provided.
|
||
if reference is None:
|
||
assert hasattr(self, 'reference'), 'error reference not provided!'
|
||
reference = self.reference
|
||
assert _is_kebab_case(reference), 'error reference must be kebab-case!'
|
||
|
||
self.kind = kind
|
||
self.reference = reference
|
||
|
||
self.message = message
|
||
self.context = context
|
||
|
||
self.note_stmt = note_stmt
|
||
self.hint_stmt = hint_stmt
|
||
|
||
self.link = link
|
||
|
||
super().__init__(f'<{self.__class__.__name__}: {self.reference}>')
|
||
|
||
def __repr__(self) -> str:
|
||
return (
|
||
f'<{self.__class__.__name__}('
|
||
f'reference={self.reference!r}, '
|
||
f'message={self.message!r}, '
|
||
f'context={self.context!r}, '
|
||
f'note_stmt={self.note_stmt!r}, '
|
||
f'hint_stmt={self.hint_stmt!r}'
|
||
')>'
|
||
)
|
||
|
||
def __rich_console__(
|
||
self,
|
||
console: Console,
|
||
options: ConsoleOptions,
|
||
) -> RenderResult:
|
||
colour = 'red' if self.kind == 'error' else 'yellow'
|
||
|
||
yield f'[{colour} bold]{self.kind}[/]: [bold]{self.reference}[/]'
|
||
yield ''
|
||
|
||
if not options.ascii_only:
|
||
# Present the main message, with relevant context indented.
|
||
if self.context is not None:
|
||
yield _prefix_with_indent(
|
||
self.message,
|
||
console,
|
||
prefix=f'[{colour}]×[/] ',
|
||
indent=f'[{colour}]│[/] ',
|
||
)
|
||
yield _prefix_with_indent(
|
||
self.context,
|
||
console,
|
||
prefix=f'[{colour}]╰─>[/] ',
|
||
indent=f'[{colour}] [/] ',
|
||
)
|
||
else:
|
||
yield _prefix_with_indent(
|
||
self.message,
|
||
console,
|
||
prefix='[red]×[/] ',
|
||
indent=' ',
|
||
)
|
||
else:
|
||
yield self.message
|
||
if self.context is not None:
|
||
yield ''
|
||
yield self.context
|
||
|
||
if self.note_stmt is not None or self.hint_stmt is not None:
|
||
yield ''
|
||
|
||
if self.note_stmt is not None:
|
||
yield _prefix_with_indent(
|
||
self.note_stmt,
|
||
console,
|
||
prefix='[magenta bold]note[/]: ',
|
||
indent=' ',
|
||
)
|
||
if self.hint_stmt is not None:
|
||
yield _prefix_with_indent(
|
||
self.hint_stmt,
|
||
console,
|
||
prefix='[cyan bold]hint[/]: ',
|
||
indent=' ',
|
||
)
|
||
|
||
if self.link is not None:
|
||
yield ''
|
||
yield f'Link: {self.link}'
|
||
|
||
|
||
#
|
||
# Actual Errors
|
||
#
|
||
class ConfigurationError(PipError):
|
||
"""General exception in configuration"""
|
||
|
||
|
||
class InstallationError(PipError):
|
||
"""General exception during installation"""
|
||
|
||
|
||
class UninstallationError(PipError):
|
||
"""General exception during uninstallation"""
|
||
|
||
|
||
class MissingPyProjectBuildRequires(DiagnosticPipError):
|
||
"""Raised when pyproject.toml has `build-system`, but no `build-system.requires`."""
|
||
|
||
reference = 'missing-pyproject-build-system-requires'
|
||
|
||
def __init__(self, *, package: str) -> None:
|
||
super().__init__(
|
||
message=f'Can not process {escape(package)}',
|
||
context=Text(
|
||
'This package has an invalid pyproject.toml file.\n'
|
||
'The [build-system] table is missing the mandatory `requires` key.',
|
||
),
|
||
note_stmt='This is an issue with the package mentioned above, not pip.',
|
||
hint_stmt=Text('See PEP 518 for the detailed specification.'),
|
||
)
|
||
|
||
|
||
class InvalidPyProjectBuildRequires(DiagnosticPipError):
|
||
"""Raised when pyproject.toml an invalid `build-system.requires`."""
|
||
|
||
reference = 'invalid-pyproject-build-system-requires'
|
||
|
||
def __init__(self, *, package: str, reason: str) -> None:
|
||
super().__init__(
|
||
message=f'Can not process {escape(package)}',
|
||
context=Text(
|
||
'This package has an invalid `build-system.requires` key in '
|
||
f'pyproject.toml.\n{reason}',
|
||
),
|
||
note_stmt='This is an issue with the package mentioned above, not pip.',
|
||
hint_stmt=Text('See PEP 518 for the detailed specification.'),
|
||
)
|
||
|
||
|
||
class NoneMetadataError(PipError):
|
||
"""Raised when accessing a Distribution's "METADATA" or "PKG-INFO".
|
||
|
||
This signifies an inconsistency, when the Distribution claims to have
|
||
the metadata file (if not, raise ``FileNotFoundError`` instead), but is
|
||
not actually able to produce its content. This may be due to permission
|
||
errors.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
dist: BaseDistribution,
|
||
metadata_name: str,
|
||
) -> None:
|
||
"""
|
||
:param dist: A Distribution object.
|
||
:param metadata_name: The name of the metadata being accessed
|
||
(can be "METADATA" or "PKG-INFO").
|
||
"""
|
||
self.dist = dist
|
||
self.metadata_name = metadata_name
|
||
|
||
def __str__(self) -> str:
|
||
# Use `dist` in the error message because its stringification
|
||
# includes more information, like the version and location.
|
||
return 'None {} metadata found for distribution: {}'.format(
|
||
self.metadata_name,
|
||
self.dist,
|
||
)
|
||
|
||
|
||
class UserInstallationInvalid(InstallationError):
|
||
"""A --user install is requested on an environment without user site."""
|
||
|
||
def __str__(self) -> str:
|
||
return 'User base directory is not specified'
|
||
|
||
|
||
class InvalidSchemeCombination(InstallationError):
|
||
def __str__(self) -> str:
|
||
before = ', '.join(str(a) for a in self.args[:-1])
|
||
return f'Cannot set {before} and {self.args[-1]} together'
|
||
|
||
|
||
class DistributionNotFound(InstallationError):
|
||
"""Raised when a distribution cannot be found to satisfy a requirement"""
|
||
|
||
|
||
class RequirementsFileParseError(InstallationError):
|
||
"""Raised when a general error occurs parsing a requirements file line."""
|
||
|
||
|
||
class BestVersionAlreadyInstalled(PipError):
|
||
"""Raised when the most up-to-date version of a package is already
|
||
installed."""
|
||
|
||
|
||
class BadCommand(PipError):
|
||
"""Raised when virtualenv or a command is not found"""
|
||
|
||
|
||
class CommandError(PipError):
|
||
"""Raised when there is an error in command-line arguments"""
|
||
|
||
|
||
class PreviousBuildDirError(PipError):
|
||
"""Raised when there's a previous conflicting build directory"""
|
||
|
||
|
||
class NetworkConnectionError(PipError):
|
||
"""HTTP connection error"""
|
||
|
||
def __init__(
|
||
self, error_msg: str, response: Response = None, request: Request = None,
|
||
) -> None:
|
||
"""
|
||
Initialize NetworkConnectionError with `request` and `response`
|
||
objects.
|
||
"""
|
||
self.response = response
|
||
self.request = request
|
||
self.error_msg = error_msg
|
||
if (
|
||
self.response is not None and
|
||
not self.request and
|
||
hasattr(response, 'request')
|
||
):
|
||
self.request = self.response.request
|
||
super().__init__(error_msg, response, request)
|
||
|
||
def __str__(self) -> str:
|
||
return str(self.error_msg)
|
||
|
||
|
||
class InvalidWheelFilename(InstallationError):
|
||
"""Invalid wheel filename."""
|
||
|
||
|
||
class UnsupportedWheel(InstallationError):
|
||
"""Unsupported wheel."""
|
||
|
||
|
||
class InvalidWheel(InstallationError):
|
||
"""Invalid (e.g. corrupt) wheel."""
|
||
|
||
def __init__(self, location: str, name: str):
|
||
self.location = location
|
||
self.name = name
|
||
|
||
def __str__(self) -> str:
|
||
return f"Wheel '{self.name}' located at {self.location} is invalid."
|
||
|
||
|
||
class MetadataInconsistent(InstallationError):
|
||
"""Built metadata contains inconsistent information.
|
||
|
||
This is raised when the metadata contains values (e.g. name and version)
|
||
that do not match the information previously obtained from sdist filename
|
||
or user-supplied ``#egg=`` value.
|
||
"""
|
||
|
||
def __init__(
|
||
self, ireq: InstallRequirement, field: str, f_val: str, m_val: str,
|
||
) -> None:
|
||
self.ireq = ireq
|
||
self.field = field
|
||
self.f_val = f_val
|
||
self.m_val = m_val
|
||
|
||
def __str__(self) -> str:
|
||
template = (
|
||
'Requested {} has inconsistent {}: '
|
||
'filename has {!r}, but metadata has {!r}'
|
||
)
|
||
return template.format(self.ireq, self.field, self.f_val, self.m_val)
|
||
|
||
|
||
class LegacyInstallFailure(DiagnosticPipError):
|
||
"""Error occurred while executing `setup.py install`"""
|
||
|
||
reference = 'legacy-install-failure'
|
||
|
||
def __init__(self, package_details: str) -> None:
|
||
super().__init__(
|
||
message='Encountered error while trying to install package.',
|
||
context=package_details,
|
||
hint_stmt='See above for output from the failure.',
|
||
note_stmt='This is an issue with the package mentioned above, not pip.',
|
||
)
|
||
|
||
|
||
class InstallationSubprocessError(DiagnosticPipError, InstallationError):
|
||
"""A subprocess call failed."""
|
||
|
||
reference = 'subprocess-exited-with-error'
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
command_description: str,
|
||
exit_code: int,
|
||
output_lines: list[str] | None,
|
||
) -> None:
|
||
if output_lines is None:
|
||
output_prompt = Text('See above for output.')
|
||
else:
|
||
output_prompt = (
|
||
Text.from_markup(f'[red][{len(output_lines)} lines of output][/]\n') +
|
||
Text(''.join(output_lines)) +
|
||
Text.from_markup(R'[red]\[end of output][/]')
|
||
)
|
||
|
||
super().__init__(
|
||
message=(
|
||
f'[green]{escape(command_description)}[/] did not run successfully.\n'
|
||
f'exit code: {exit_code}'
|
||
),
|
||
context=output_prompt,
|
||
hint_stmt=None,
|
||
note_stmt=(
|
||
'This error originates from a subprocess, and is likely not a '
|
||
'problem with pip.'
|
||
),
|
||
)
|
||
|
||
self.command_description = command_description
|
||
self.exit_code = exit_code
|
||
|
||
def __str__(self) -> str:
|
||
return f'{self.command_description} exited with {self.exit_code}'
|
||
|
||
|
||
class MetadataGenerationFailed(InstallationSubprocessError, InstallationError):
|
||
reference = 'metadata-generation-failed'
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
package_details: str,
|
||
) -> None:
|
||
super(InstallationSubprocessError, self).__init__(
|
||
message='Encountered error while generating package metadata.',
|
||
context=escape(package_details),
|
||
hint_stmt='See above for details.',
|
||
note_stmt='This is an issue with the package mentioned above, not pip.',
|
||
)
|
||
|
||
def __str__(self) -> str:
|
||
return 'metadata generation failed'
|
||
|
||
|
||
class HashErrors(InstallationError):
|
||
"""Multiple HashError instances rolled into one for reporting"""
|
||
|
||
def __init__(self) -> None:
|
||
self.errors: list[HashError] = []
|
||
|
||
def append(self, error: HashError) -> None:
|
||
self.errors.append(error)
|
||
|
||
def __str__(self) -> str:
|
||
lines = []
|
||
self.errors.sort(key=lambda e: e.order)
|
||
for cls, errors_of_cls in groupby(self.errors, lambda e: e.__class__):
|
||
lines.append(cls.head)
|
||
lines.extend(e.body() for e in errors_of_cls)
|
||
if lines:
|
||
return '\n'.join(lines)
|
||
return ''
|
||
|
||
def __bool__(self) -> bool:
|
||
return bool(self.errors)
|
||
|
||
|
||
class HashError(InstallationError):
|
||
"""
|
||
A failure to verify a package against known-good hashes
|
||
|
||
:cvar order: An int sorting hash exception classes by difficulty of
|
||
recovery (lower being harder), so the user doesn't bother fretting
|
||
about unpinned packages when he has deeper issues, like VCS
|
||
dependencies, to deal with. Also keeps error reports in a
|
||
deterministic order.
|
||
:cvar head: A section heading for display above potentially many
|
||
exceptions of this kind
|
||
:ivar req: The InstallRequirement that triggered this error. This is
|
||
pasted on after the exception is instantiated, because it's not
|
||
typically available earlier.
|
||
|
||
"""
|
||
|
||
req: InstallRequirement | None = None
|
||
head = ''
|
||
order: int = -1
|
||
|
||
def body(self) -> str:
|
||
"""Return a summary of me for display under the heading.
|
||
|
||
This default implementation simply prints a description of the
|
||
triggering requirement.
|
||
|
||
:param req: The InstallRequirement that provoked this error, with
|
||
its link already populated by the resolver's _populate_link().
|
||
|
||
"""
|
||
return f' {self._requirement_name()}'
|
||
|
||
def __str__(self) -> str:
|
||
return f'{self.head}\n{self.body()}'
|
||
|
||
def _requirement_name(self) -> str:
|
||
"""Return a description of the requirement that triggered me.
|
||
|
||
This default implementation returns long description of the req, with
|
||
line numbers
|
||
|
||
"""
|
||
return str(self.req) if self.req else 'unknown package'
|
||
|
||
|
||
class VcsHashUnsupported(HashError):
|
||
"""A hash was provided for a version-control-system-based requirement, but
|
||
we don't have a method for hashing those."""
|
||
|
||
order = 0
|
||
head = (
|
||
"Can't verify hashes for these requirements because we don't "
|
||
'have a way to hash version control repositories:'
|
||
)
|
||
|
||
|
||
class DirectoryUrlHashUnsupported(HashError):
|
||
"""A hash was provided for a version-control-system-based requirement, but
|
||
we don't have a method for hashing those."""
|
||
|
||
order = 1
|
||
head = (
|
||
"Can't verify hashes for these file:// requirements because they "
|
||
'point to directories:'
|
||
)
|
||
|
||
|
||
class HashMissing(HashError):
|
||
"""A hash was needed for a requirement but is absent."""
|
||
|
||
order = 2
|
||
head = (
|
||
'Hashes are required in --require-hashes mode, but they are '
|
||
'missing from some requirements. Here is a list of those '
|
||
'requirements along with the hashes their downloaded archives '
|
||
'actually had. Add lines like these to your requirements files to '
|
||
'prevent tampering. (If you did not enable --require-hashes '
|
||
'manually, note that it turns on automatically when any package '
|
||
'has a hash.)'
|
||
)
|
||
|
||
def __init__(self, gotten_hash: str) -> None:
|
||
"""
|
||
:param gotten_hash: The hash of the (possibly malicious) archive we
|
||
just downloaded
|
||
"""
|
||
self.gotten_hash = gotten_hash
|
||
|
||
def body(self) -> str:
|
||
# Dodge circular import.
|
||
from pip._internal.utils.hashes import FAVORITE_HASH
|
||
|
||
package = None
|
||
if self.req:
|
||
# In the case of URL-based requirements, display the original URL
|
||
# seen in the requirements file rather than the package name,
|
||
# so the output can be directly copied into the requirements file.
|
||
package = (
|
||
self.req.original_link
|
||
if self.req.original_link
|
||
# In case someone feeds something downright stupid
|
||
# to InstallRequirement's constructor.
|
||
else getattr(self.req, 'req', None)
|
||
)
|
||
return ' {} --hash={}:{}'.format(
|
||
package or 'unknown package', FAVORITE_HASH, self.gotten_hash,
|
||
)
|
||
|
||
|
||
class HashUnpinned(HashError):
|
||
"""A requirement had a hash specified but was not pinned to a specific
|
||
version."""
|
||
|
||
order = 3
|
||
head = (
|
||
'In --require-hashes mode, all requirements must have their '
|
||
'versions pinned with ==. These do not:'
|
||
)
|
||
|
||
|
||
class HashMismatch(HashError):
|
||
"""
|
||
Distribution file hash values don't match.
|
||
|
||
:ivar package_name: The name of the package that triggered the hash
|
||
mismatch. Feel free to write to this after the exception is raise to
|
||
improve its error message.
|
||
|
||
"""
|
||
|
||
order = 4
|
||
head = (
|
||
'THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS '
|
||
'FILE. If you have updated the package versions, please update '
|
||
'the hashes. Otherwise, examine the package contents carefully; '
|
||
'someone may have tampered with them.'
|
||
)
|
||
|
||
def __init__(self, allowed: dict[str, list[str]], gots: dict[str, _Hash]) -> None:
|
||
"""
|
||
:param allowed: A dict of algorithm names pointing to lists of allowed
|
||
hex digests
|
||
:param gots: A dict of algorithm names pointing to hashes we
|
||
actually got from the files under suspicion
|
||
"""
|
||
self.allowed = allowed
|
||
self.gots = gots
|
||
|
||
def body(self) -> str:
|
||
return f' {self._requirement_name()}:\n{self._hash_comparison()}'
|
||
|
||
def _hash_comparison(self) -> str:
|
||
"""
|
||
Return a comparison of actual and expected hash values.
|
||
|
||
Example::
|
||
|
||
Expected sha256 abcdeabcdeabcdeabcdeabcdeabcdeabcdeabcdeabcde
|
||
or 123451234512345123451234512345123451234512345
|
||
Got bcdefbcdefbcdefbcdefbcdefbcdefbcdefbcdefbcdef
|
||
|
||
"""
|
||
|
||
def hash_then_or(hash_name: str) -> chain[str]:
|
||
# For now, all the decent hashes have 6-char names, so we can get
|
||
# away with hard-coding space literals.
|
||
return chain([hash_name], repeat(' or'))
|
||
|
||
lines: list[str] = []
|
||
for hash_name, expecteds in self.allowed.items():
|
||
prefix = hash_then_or(hash_name)
|
||
lines.extend(
|
||
(f' Expected {next(prefix)} {e}') for e in expecteds
|
||
)
|
||
lines.append(
|
||
f' Got {self.gots[hash_name].hexdigest()}\n',
|
||
)
|
||
return '\n'.join(lines)
|
||
|
||
|
||
class UnsupportedPythonVersion(InstallationError):
|
||
"""Unsupported python version according to Requires-Python package
|
||
metadata."""
|
||
|
||
|
||
class ConfigurationFileCouldNotBeLoaded(ConfigurationError):
|
||
"""When there are errors while loading a configuration file"""
|
||
|
||
def __init__(
|
||
self,
|
||
reason: str = 'could not be loaded',
|
||
fname: str | None = None,
|
||
error: configparser.Error | None = None,
|
||
) -> None:
|
||
super().__init__(error)
|
||
self.reason = reason
|
||
self.fname = fname
|
||
self.error = error
|
||
|
||
def __str__(self) -> str:
|
||
if self.fname is not None:
|
||
message_part = f' in {self.fname}.'
|
||
else:
|
||
assert self.error is not None
|
||
message_part = f'.\n{self.error}\n'
|
||
return f'Configuration file {self.reason}{message_part}'
|