Squashed integration of the user-facing message audit for this surface set. Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts).
119 lines
5.2 KiB
Python
119 lines
5.2 KiB
Python
"""Plain-language copy for sign-in and provider-setup failures (CLI).
|
|
|
|
One table of ``(predicate, lead sentence)`` classifies an exception into a sentence a first-time
|
|
user can act on; the raw exception is demoted to a ``Details:`` line so nothing is lost for
|
|
support. Callers print ``sign_in_failure_lines(...)`` / ``provider_setup_failure_lines(...)``
|
|
line by line.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Callable, Sequence, Tuple
|
|
|
|
# httpx class names and stdlib bases that mean "the request never got a usable answer".
|
|
_NETWORK_ERROR_TYPES = frozenset({
|
|
"ConnectError", "ConnectTimeout", "ReadTimeout", "PoolTimeout", "WriteTimeout", "TimeoutException",
|
|
"RemoteProtocolError", "ReadError", "ProxyError", "UnsupportedProtocol", "NetworkError",
|
|
})
|
|
|
|
# OAuth device-flow error codes (RFC 8628 §3.5) -> plain copy. ``{retry}`` is the retry command.
|
|
DEVICE_FLOW_ERROR_COPY = {
|
|
"expired_token": (
|
|
"The sign-in code expired before it was approved in the browser. Run `{retry}` to get a new code."),
|
|
"access_denied": (
|
|
"Sign-in was declined in the browser. Run `{retry}` to try again, or `hermes model` to pick a "
|
|
"different provider."),
|
|
"invalid_grant": (
|
|
"The sign-in code was not accepted by the server. Run `{retry}` to get a new code."),
|
|
"invalid_client": (
|
|
"The server did not recognize this copy of Hermes. Run `hermes update`, then `{retry}` again."),
|
|
}
|
|
|
|
|
|
class SignInCopyError(RuntimeError):
|
|
"""Exception whose ``str()`` is already user copy (lead line + ``Details:`` line)."""
|
|
|
|
def __init__(self, message: str, *, oauth_error_code: str = "") -> None:
|
|
super().__init__(message)
|
|
self.oauth_error_code = oauth_error_code
|
|
|
|
|
|
def is_network_error(exc: BaseException) -> bool:
|
|
"""True for connection/DNS/timeout failures from httpx, requests or the stdlib."""
|
|
if isinstance(exc, SignInCopyError):
|
|
return False
|
|
names = {cls.__name__ for cls in type(exc).__mro__}
|
|
return bool(names & _NETWORK_ERROR_TYPES) or isinstance(exc, (ConnectionError, TimeoutError))
|
|
|
|
|
|
def is_cancelled(exc: BaseException) -> bool:
|
|
return isinstance(exc, (KeyboardInterrupt, EOFError)) or (
|
|
isinstance(exc, SystemExit) and exc.code in (130, None, 0))
|
|
|
|
|
|
def device_flow_error(code: str, description: str, *, retry_command: str = "hermes portal") -> SignInCopyError:
|
|
"""Exception for an OAuth device-flow error code whose text is already user-facing.
|
|
|
|
Unknown codes keep the server's description as the lead (it is the only information available)
|
|
but still name the retry command.
|
|
"""
|
|
lead = DEVICE_FLOW_ERROR_COPY.get(code, "").format(retry=retry_command)
|
|
if not lead:
|
|
lead = (f"Sign-in did not complete: {description or 'the server rejected the request'}. "
|
|
f"Run `{retry_command}` to try again.")
|
|
details = f"{code}: {description}" if code else description
|
|
return SignInCopyError(f"{lead}\n Details: {details}" if details else lead, oauth_error_code=code)
|
|
|
|
|
|
def _details_line(exc: BaseException) -> str:
|
|
text = str(exc).strip() or type(exc).__name__
|
|
return f" Details: {text}"
|
|
|
|
|
|
_Rule = Tuple[Callable[[BaseException], bool], str]
|
|
|
|
|
|
def _classify(exc: BaseException, rules: Sequence[_Rule], other: str) -> str:
|
|
return next((copy for pred, copy in rules if pred(exc)), other)
|
|
|
|
|
|
def sign_in_failure_lines(
|
|
exc: BaseException, *, service_host: str = "portal.nousresearch.com", retry_command: str = "hermes portal",
|
|
) -> list:
|
|
"""Lines to print when a device-code / browser sign-in fails for any non-timeout reason."""
|
|
if isinstance(exc, SignInCopyError):
|
|
return str(exc).splitlines()
|
|
rules: Sequence[_Rule] = (
|
|
(is_cancelled, "Sign-in was cancelled. Run `{retry}` when you want to try again."),
|
|
(is_network_error,
|
|
"Could not sign in: Hermes could not reach {host}. Check your internet connection or proxy, "
|
|
"then run `{retry}` again."),
|
|
)
|
|
lead = _classify(
|
|
exc, rules,
|
|
"Could not sign in. Run `{retry}` to try again, or `hermes model` to pick a different provider.")
|
|
lines = [lead.format(host=service_host, retry=retry_command)]
|
|
if not is_cancelled(exc):
|
|
lines.append(_details_line(exc))
|
|
return lines
|
|
|
|
|
|
def provider_setup_failure_lines(exc: BaseException, *, retry_command: str = "hermes model") -> list:
|
|
"""Lines to print when the setup wizard's provider step fails: reason, that nothing was saved,
|
|
and how to retry."""
|
|
nothing_saved = (
|
|
"Your provider settings were not changed. Continue the wizard now and run "
|
|
f"`{retry_command}` afterwards to try again.")
|
|
if isinstance(exc, SignInCopyError):
|
|
lead, *details = str(exc).splitlines()
|
|
return [f"Could not finish connecting a provider: {lead[0].lower()}{lead[1:]}", nothing_saved, *details]
|
|
rules: Sequence[_Rule] = (
|
|
(is_cancelled, "sign-in was cancelled"),
|
|
(is_network_error, "no internet connection, or the provider could not be reached"),
|
|
)
|
|
reason = _classify(exc, rules, "something went wrong while talking to the provider")
|
|
lines = [f"Could not finish connecting a provider ({reason}).", nothing_saved]
|
|
if not is_cancelled(exc):
|
|
lines.append(_details_line(exc))
|
|
return lines
|