Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a better way to display errors to users, and implement initial cases (chooser, vcs backend) #10065

Merged
merged 3 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/poetry/console/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from poetry.__version__ import __version__
from poetry.console.command_loader import CommandLoader
from poetry.console.commands.command import Command
from poetry.console.exceptions import PoetryRuntimeError
from poetry.utils.helpers import directory
from poetry.utils.helpers import ensure_path

Expand Down Expand Up @@ -246,9 +247,15 @@ def _run(self, io: IO) -> int:

self._load_plugins(io)

exit_code: int = 1

with directory(self._working_directory):
try:
exit_code: int = super()._run(io)
exit_code = super()._run(io)
except PoetryRuntimeError as e:
io.write_error_line("")
e.write(io)
io.write_error_line("")
except CleoCommandNotFoundError as e:
command = self._get_command_name(io)

Expand Down
213 changes: 213 additions & 0 deletions src/poetry/console/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,224 @@
from __future__ import annotations

import dataclasses
import shlex

from dataclasses import InitVar
from subprocess import CalledProcessError
from typing import TYPE_CHECKING

from cleo.exceptions import CleoError

from poetry.utils._compat import decode


if TYPE_CHECKING:
from cleo.io.io import IO


class PoetryConsoleError(CleoError):
pass


class GroupNotFoundError(PoetryConsoleError):
pass


@dataclasses.dataclass
class ConsoleMessage:
"""
Representation of a console message, providing utilities for formatting text
with tags, indentation, and sections.

The ConsoleMessage class is designed to represent text messages that might be
displayed in a console or terminal output. It provides features for managing
formatted text, such as stripping tags, wrapping text with specific tags,
indenting, and creating structured message sections.
"""

text: str
debug: bool = False

@property
def stripped(self) -> str:
from cleo._utils import strip_tags

return strip_tags(self.text)

def wrap(self, tag: str) -> ConsoleMessage:
if self.text:
self.text = f"<{tag}>{self.text}</>"
return self

def indent(self, indent: str) -> ConsoleMessage:
if self.text:
self.text = f"\n{indent}".join(self.text.splitlines()).strip()
self.text = f"{indent}{self.text}"
return self

def make_section(
self,
title: str,
indent: str = "",
) -> ConsoleMessage:
if not self.text:
return self

if self.text:
section = [f"<b>{title}:</>"] if title else []
section.extend(self.text.splitlines())
self.text = f"\n{indent}".join(section).strip()

return self


@dataclasses.dataclass
class PrettyCalledProcessError:
"""
Represents a formatted and decorated error object for a subprocess call.

This class is used to encapsulate information about a `CalledProcessError`,
providing additional context such as command output, errors, and helpful
debugging messages. It is particularly useful for wrapping and decorating
subprocess-related exceptions in a more user-friendly format.

Attributes:
message: A string representation of the exception.
output: A section formatted representation of the exception stdout.
errors: A section formatted representation of the exception stderr.
command_message: Formatted message including a hint on retrying the original command.
command: A `shelex` quoted string representation of the original command.
exception: The original `CalledProcessError` instance.
indent: Indent prefix to use for inner content per section.
"""

message: ConsoleMessage = dataclasses.field(init=False)
output: ConsoleMessage = dataclasses.field(init=False)
errors: ConsoleMessage = dataclasses.field(init=False)
command_message: ConsoleMessage = dataclasses.field(init=False)
command: str = dataclasses.field(init=False)
exception: InitVar[CalledProcessError] = dataclasses.field(init=True)
indent: InitVar[str] = dataclasses.field(default="")

def __post_init__(self, exception: CalledProcessError, indent: str = "") -> None:
self.message = ConsoleMessage(str(exception).strip(), debug=True).make_section(
"Exception", indent
)
self.output = ConsoleMessage(decode(exception.stdout), debug=True).make_section(
"Output", indent
)
self.errors = ConsoleMessage(decode(exception.stderr), debug=True).make_section(
"Errors", indent
)
self.command = (
shlex.join(exception.cmd)
if isinstance(exception.cmd, list)
else exception.cmd
)
self.command_message = ConsoleMessage(
f"You can test the failed command by executing:\n\n <c1>{self.command}</c1>",
debug=False,
)


class PoetryRuntimeError(PoetryConsoleError):
"""
Represents a runtime error in the Poetry console application.
"""

def __init__(
self,
reason: str,
messages: list[ConsoleMessage] | None = None,
exit_code: int = 1,
) -> None:
super().__init__(reason)
self.exit_code = exit_code
self._messages = messages or []
self._messages.insert(0, ConsoleMessage(reason))

def write(self, io: IO) -> None:
"""
Write the error text to the provided IO iff there is any text
to write.
"""
if text := self.get_text(debug=io.is_verbose(), strip=False):
io.write_error_line(text)

def get_text(
self, debug: bool = False, indent: str = "", strip: bool = False
) -> str:
"""
Convert the error messages to a formatted string. All empty messages
are ignored along with debug level messages if `debug` is `False`.
"""
text = ""
has_skipped_debug = False

for message in self._messages:
if message.debug and not debug:
has_skipped_debug = True
continue

message_text = message.stripped if strip else message.text
if not message_text:
continue

if indent:
message_text = f"\n{indent}".join(message_text.splitlines())

text += f"{indent}{message_text}\n{indent}\n"

if has_skipped_debug:
text += f"{indent}You can also run your <c1>poetry</> command with <c1>-v</> to see more information.\n{indent}\n"

return text.rstrip(f"{indent}\n")

def __str__(self) -> str:
return self._messages[0].stripped.strip()

@classmethod
def create(
cls,
reason: str,
exception: CalledProcessError | Exception | None = None,
info: list[str] | str | None = None,
) -> PoetryRuntimeError:
"""
Create an instance of this class using the provided reason. If
an exception is provided, this is also injected as a debug
`ConsoleMessage`.

There is specific handling for known exception types. For example,
if exception is of type `subprocess.CalledProcessError`, the following
sections are additionally added when available - stdout, stderr and
command for testing.
"""
if isinstance(info, str):
info = [info]

messages: list[ConsoleMessage] = [
ConsoleMessage(
"\n".join(info or []),
debug=False,
).wrap("info"),
]

if isinstance(exception, CalledProcessError):
error = PrettyCalledProcessError(exception, indent=" | ")
messages = [
error.message.wrap("warning"),
error.output.wrap("warning"),
error.errors.wrap("warning"),
*messages,
error.command_message,
]
elif exception is not None and isinstance(exception, Exception):
messages.insert(
0,
ConsoleMessage(str(exception), debug=True).make_section(
"Exception", indent=" | "
),
)

return cls(reason, messages)
29 changes: 24 additions & 5 deletions src/poetry/installation/chooser.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from poetry.config.config import Config
from poetry.config.config import PackageFilterPolicy
from poetry.console.exceptions import ConsoleMessage
from poetry.console.exceptions import PoetryRuntimeError
from poetry.repositories.http_repository import HTTPRepository
from poetry.utils.helpers import get_highest_priority_hash_type
from poetry.utils.wheel import Wheel
Expand Down Expand Up @@ -134,11 +136,28 @@ def _get_links(self, package: Package) -> list[Link]:
selected_links.append(link)

if links and not selected_links:
links_str = ", ".join(f"{link}({h})" for link, h in skipped)
raise RuntimeError(
f"Retrieved digests for links {links_str} not in poetry.lock"
f" metadata {locked_hashes}"
)
reason = f"Downloaded distributions for <b>{package.pretty_name} ({package.pretty_version})</> did not match any known checksums in your lock file."
link_hashes = "\n".join(f" - {link}({h})" for link, h in skipped)
known_hashes = "\n".join(f" - {h}" for h in locked_hashes)
messages = [
ConsoleMessage(
"<options=bold>Causes:</>\n"
" - invalid or corrupt cache either during locking or installation\n"
" - network interruptions or errors causing corrupted downloads\n\n"
"<b>Solutions:</>\n"
" 1. Try running your command again using the <c1>--no-cache</> global option enabled.\n"
" 2. Try regenerating your lock file using (<c1>poetry lock --no-cache --regenerate</>).\n\n"
"If any of those solutions worked, you will have to clear your caches using (<c1>poetry cache clear --all CACHE_NAME</>)."
),
ConsoleMessage(
f"Poetry retrieved the following links:\n"
f"{link_hashes}\n\n"
f"The lockfile contained only the following hashes:\n"
f"{known_hashes}",
debug=True,
),
]
raise PoetryRuntimeError(reason, messages)

return selected_links

Expand Down
5 changes: 5 additions & 0 deletions src/poetry/installation/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from poetry.core.packages.utils.link import Link

from poetry.console.exceptions import PoetryRuntimeError
from poetry.installation.chef import Chef
from poetry.installation.chooser import Chooser
from poetry.installation.operations import Install
Expand Down Expand Up @@ -333,6 +334,10 @@ def _execute_operation(self, operation: Operation) -> None:
f" for {pkg.pretty_name}."
"</error>"
)
elif isinstance(e, PoetryRuntimeError):
message = e.get_text(io.is_verbose(), indent=" | ").rstrip()
message = f"<warning>{message}</>"
with_trace = False
else:
message = f"<error>Cannot install {pkg.pretty_name}.</error>"

Expand Down
Loading
Loading