import inspect import json from collections.abc import Callable, Iterator, Sequence from enum import Enum from itertools import chain from typing import TYPE_CHECKING, Any, Literal, Optional, get_args, get_origin from attrs import define, field import cyclopts.utils from cyclopts.annotations import get_hint_name from cyclopts.command_spec import CommandSpec from cyclopts.group import Group from cyclopts.token import Token from cyclopts.utils import is_option_like, json_decode_error_verbosifier, slice_to_str if TYPE_CHECKING: from rich.console import Console from rich.text import Text from cyclopts.argument import Argument, ArgumentCollection from cyclopts.core import App STYLE_OFFENDING_VALUE = "bold red" """Rich style for an offending user-supplied value.""" STYLE_NAME = "bold" """Rich style for a parameter, option, or command name.""" STYLE_VALID_CHOICE = "cyan" """Rich style for valid choices in ``Choose from:`` lists.""" STYLE_SUGGESTION = "bold green" """Rich style for ``Did you mean ...`` suggestions.""" STYLE_SOURCE = "dim" """Rich style for source suffixes like ``from ``.""" __all__ = [ "CoercionError", "CommandCollisionError", "CycloptsError", "DocstringError", "UnknownCommandError", "MissingArgumentError", "ConsumeMultipleError", "MixedArgumentError", "RepeatArgumentError", "RequiresEqualsError", "UnknownOptionError", "UnusedCliTokensError", "ValidationError", "CombinedShortOptionError", "STYLE_OFFENDING_VALUE", "STYLE_NAME", "STYLE_VALID_CHOICE", "STYLE_SUGGESTION", "STYLE_SOURCE", ] def _get_function_info(func): return inspect.getsourcefile(func), inspect.getsourcelines(func)[1] class CommandCollisionError(Exception): """A command with the same name has already been registered to the app.""" # This doesn't derive from CycloptsError since this is a developer error # rather than a runtime error. class DocstringError(Exception): """The docstring either has a syntax error, or inconsistency with the function signature.""" @define # (kw_only=True) class CycloptsError(Exception): """Root exception for runtime errors. As CycloptsErrors bubble up the Cyclopts call-stack, more information is added to it. """ msg: "str | Text | None" = None """ If set, override automatic message generation. A :class:`str` is rendered as literal text. To apply styling, pass a :class:`rich.text.Text` instance — either constructed from `Rich console markup `_:: from rich.text import Text raise CycloptsError(msg=Text.from_markup("Invalid value [bold red]foo[/].")) or assembled directly using the built-in palette (:data:`STYLE_OFFENDING_VALUE`, :data:`STYLE_NAME`, :data:`STYLE_VALID_CHOICE`, :data:`STYLE_SUGGESTION`, :data:`STYLE_SOURCE`) to keep custom errors visually consistent with the framework's output:: from rich.text import Text from cyclopts.exceptions import STYLE_NAME, STYLE_OFFENDING_VALUE t = Text("Invalid value ") t.append("foo", style=STYLE_OFFENDING_VALUE) t.append(" for ") t.append("--name", style=STYLE_NAME) raise CycloptsError(msg=t) """ verbose: bool = True """ More verbose error messages; aimed towards developers debugging their Cyclopts app. Defaults to ``False``. """ root_input_tokens: list[str] | None = None """ The parsed CLI tokens that were initially fed into the :class:`App`. """ unused_tokens: list[str] | None = None """ Leftover tokens after parsing is complete. """ target: Callable | None = None """ The python function associated with the command being parsed. """ argument: Optional["Argument"] = None """ :class:`Argument` that was matched. """ command_chain: Sequence[str] | None = None """ List of command that lead to ``target``. """ app: Optional["App"] = None """ The Cyclopts application itself. """ console: Optional["Console"] = field(default=None, kw_only=True) """:class:`~rich.console.Console` to display runtime errors.""" def _resolved_msg(self) -> "Text": """Resolve ``self.msg`` (str | Text) into a Rich ``Text`` instance. Strings are wrapped as literal text -- only ``Text`` instances carry styling, so the caller opts in by constructing one explicitly. """ from rich.text import Text assert self.msg is not None if isinstance(self.msg, Text): return self.msg return Text(self.msg) def _segments(self) -> "Iterator[tuple[str, str] | Text]": """Yield segments that compose the error message. Each item is either a ``(text, style)`` tuple (empty string = no styling) or a pre-rendered :class:`rich.text.Text`. Drives both ``__str__`` (joins plain text) and ``__rich__`` (applies styles). Subclasses override to add their body, typically prefixing with ``yield from super()._segments()`` to include the verbose preamble. """ if self.msg is not None: yield self._resolved_msg() return if not self.verbose: return strings = [type(self).__name__] if self.target: file, lineno = _get_function_info(self.target) strings.append(f'Function defined in file "{file}", line {lineno}:') strings.append(f" {self.target.__name__}{inspect.signature(self.target)}") if self.root_input_tokens is not None: strings.append(f"Root Input Tokens: {self.root_input_tokens}") yield "\n".join(strings) + "\n", "" def __str__(self): parts: list[str] = [] for item in self._segments(): if isinstance(item, tuple): parts.append(item[0]) else: parts.append(item.plain) return "".join(parts) def __rich__(self) -> "Text": from rich.text import Text # style="default" prevents the enclosing panel's border style (e.g. "red") # from bleeding through into unstyled body segments. out = Text(style="default") for item in self._segments(): if isinstance(item, tuple): text, style = item out.append(text, style=style or None) else: out.append_text(item) return out @define(kw_only=True) class CombinedShortOptionError(CycloptsError): """Cannot combine short, token-consuming options with short flags.""" @define(kw_only=True) class ValidationError(CycloptsError): """Validator function raised an exception.""" exception_message: str = "" """Parenting Assertion/Value/Type Error message.""" group: Group | None = None """If a group validator caused the exception.""" value: Any = cyclopts.utils.UNSET """Converted value that failed validation.""" def _segments(self) -> "Iterator[tuple[str, str] | Text]": body: list[tuple[str, str]] = [] if self.argument: value = self.argument.value if self.value is cyclopts.utils.UNSET else self.value try: token = self.argument.tokens[0] except IndexError: pass else: provided_by = "" if not token.source or token.source == "cli" else f" provided by {token.source}" name = token.keyword if token.keyword else self.argument.name.lstrip("-").upper() value_str = slice_to_str(value) if isinstance(value, slice) else f"{value}" body.append(('Invalid value "', "")) body.append((value_str, STYLE_OFFENDING_VALUE)) body.append(('" for ', "")) body.append((name, STYLE_NAME)) if provided_by: body.append((provided_by, STYLE_SOURCE)) body.append((".", "")) elif self.group: if self.group.name: body.append(("Invalid values for group ", "")) body.append((self.group.name, STYLE_NAME)) body.append((".", "")) elif self.command_chain: body.append(('Invalid values for command "', "")) body.append((self.command_chain[-1], STYLE_NAME)) body.append(('".', "")) else: raise NotImplementedError preamble = list(super()._segments()) yield from preamble yield from body if self.exception_message: if preamble or body: yield " ", "" yield self.exception_message, "" @define(kw_only=True) class UnknownOptionError(CycloptsError): """Unknown/unregistered option provided by the cli. A nearest-neighbor parameter suggestion may be printed. """ token: Token """Token without a matching parameter.""" argument_collection: "ArgumentCollection" """Argument collection of plausible options.""" def _segments(self) -> "Iterator[tuple[str, str] | Text]": value = self.token.keyword or self.token.value # Option-like values (start with '-') are self-delimiting; quoting them is noise. quoted = not is_option_like(value) yield from super()._segments() yield ('Unknown option: "' if quoted else "Unknown option: "), "" yield value, STYLE_OFFENDING_VALUE close = '"' if quoted else "" if self.token.source == "cli": yield f"{close}.", "" else: if close: yield close, "" yield f" from {self.token.source}", STYLE_SOURCE yield ".", "" if keyword := self.token.keyword or self.token.value: import difflib candidates = list(chain.from_iterable(x.names for x in self.argument_collection if x.parse)) close_matches = difflib.get_close_matches(keyword, candidates, n=1, cutoff=0.6) if close_matches: yield " Did you mean ", "" yield close_matches[0], STYLE_SUGGESTION yield "?", "" @define(kw_only=True) class CoercionError(CycloptsError): """There was an error performing automatic type coercion.""" token: Optional["Token"] = None """ Input token that couldn't be coerced. """ target_type: type | None = None """ Intended type to coerce into. """ def _segments(self) -> "Iterator[tuple[str, str] | Text]": """Yield segments that compose the error message. Tuples are ``(text, style)`` pairs; ``Text`` items are emitted verbatim (so user-supplied Rich markup / ``Text`` is preserved). """ # Branch 1: explicit msg override. User-supplied markup is preserved; # the framework wraps it with the standard prefix when a keyword exists. if self.msg is not None: msg_text = self._resolved_msg() if not self.token or self.token.keyword is None: yield msg_text else: from rich.text import Text prefix = Text(f"Invalid value for {self.token.keyword}: ") yield prefix + msg_text return # Branch 2: JSONDecodeError verbosifier path. Plain, like branch 1. if isinstance(self.__cause__, json.JSONDecodeError): verbosified = json_decode_error_verbosifier(self.__cause__) # pyright: ignore[reportArgumentType] if not self.token or self.token.keyword is None: yield verbosified, "" else: yield f"Invalid value for {self.token.keyword}: {verbosified}", "" return assert self.argument is not None assert self.target_type is not None yield from super()._segments() choice_strs: list[str] | None = None plain_choices: list[str] | None = None if get_origin(self.target_type) is Literal: args = get_args(self.target_type) choice_strs = [f'"{x}"' if isinstance(x, str) else repr(x) for x in args] plain_choices = [x for x in args if isinstance(x, str)] elif isinstance(self.target_type, type) and issubclass(self.target_type, Enum): nt = self.argument.parameter.name_transform members = [nt(x) for x in self.target_type.__members__] choice_strs = [f'"{x}"' for x in members] plain_choices = members # Branch 3: Literal/Enum with a token -- "Choose from" + suggestion. if choice_strs is not None and self.token is not None: name = self.token.keyword if self.token.keyword else self.argument.name.lstrip("-").upper() yield 'Invalid value "', "" yield self.token.value, STYLE_OFFENDING_VALUE yield '" for ', "" yield name, STYLE_NAME if self.token.source not in ("", "cli"): yield f" from {self.token.source}", STYLE_SOURCE yield ". Choose from: ", "" for i, choice in enumerate(choice_strs): if i: yield ", ", "" yield choice, STYLE_VALID_CHOICE yield ".", "" import difflib close = difflib.get_close_matches(self.token.value, plain_choices or [], n=1, cutoff=0.6) if close: yield ' Did you mean "', "" yield close[0], STYLE_SUGGESTION yield '"?', "" return # Branch 4: fallback -- "unable to convert ... into ". target_type_name = ( get_hint_name(self.target_type) if choice_strs is None else f"one of {{{', '.join(choice_strs)}}}" ) if not self.token: yield "Invalid value for ", "" yield self.argument.name, STYLE_NAME yield f": unable to convert value to {target_type_name}.", "" return if self.token.keyword is None: display_name = self.argument.name.lstrip("-").upper() else: display_name = self.token.keyword yield "Invalid value for ", "" yield display_name, STYLE_NAME if self.token.source not in ("", "cli"): yield f" from {self.token.source}", STYLE_SOURCE yield ': unable to convert "', "" yield self.token.value, STYLE_OFFENDING_VALUE yield f'" into {target_type_name}.', "" class UnknownCommandError(CycloptsError): """CLI token combination did not yield a valid command.""" def _segments(self) -> "Iterator[tuple[str, str] | Text]": assert self.unused_tokens token = self.unused_tokens[0] yield from super()._segments() yield 'Unknown command "', "" yield token, STYLE_OFFENDING_VALUE yield '".', "" if not (self.app and self.app._commands): return visible_commands: list[str] = [] synonym_matches: list[str] = [] seen_subapps: set[int] = set() for name, app_or_spec in self.app._commands.items(): if name in self.app._help_flags or name in self.app._version_flags: continue subapp = app_or_spec.resolve(self.app) if isinstance(app_or_spec, CommandSpec) else app_or_spec if not isinstance(subapp, type(self.app)): continue if not subapp.show: continue visible_commands.append(name) # First registration of this subapp is its primary name; only check synonyms once per subapp. subapp_id = id(subapp) if subapp_id not in seen_subapps: seen_subapps.add(subapp_id) synonym = subapp.synonym if isinstance(synonym, str): if token == synonym: synonym_matches.append(name) elif synonym and token in synonym: synonym_matches.append(name) if synonym_matches: yield " Did you mean ", "" for i, match in enumerate(synonym_matches): if i > 0: if len(synonym_matches) == 2: yield " or ", "" elif i == len(synonym_matches) - 1: yield ", or ", "" else: yield ", ", "" yield '"', "" yield match, STYLE_SUGGESTION yield '"', "" yield "?", "" else: import difflib close_matches = difflib.get_close_matches(token, visible_commands, n=1, cutoff=0.6) if close_matches: yield ' Did you mean "', "" yield close_matches[0], STYLE_SUGGESTION yield '"?', "" # Heuristic: list the visible commands to help users who forgot the command name. max_commands = 8 available_commands = [name for name in visible_commands if not name.startswith("-")] if not available_commands: return yield " Available commands: ", "" if len(available_commands) > max_commands: shown = available_commands[:max_commands] for i, name in enumerate(shown): if i: yield ", ", "" yield name, STYLE_VALID_CHOICE yield ", ...", "" else: for i, name in enumerate(available_commands): if i: yield ", ", "" yield name, STYLE_VALID_CHOICE yield ".", "" @define(kw_only=True) class UnusedCliTokensError(CycloptsError): """Not all CLI tokens were used as expected.""" def _segments(self) -> "Iterator[tuple[str, str] | Text]": assert self.unused_tokens is not None yield from super()._segments() yield f"Unused Tokens: {self.unused_tokens}.", "" @define(kw_only=True) class MissingArgumentError(CycloptsError): """A required argument was not provided.""" tokens_so_far: list[str] = field(factory=list) """If the matched parameter requires multiple tokens, these are the ones we have parsed so far.""" keyword: str | None = None """The keyword that was used when the error was raised (e.g., '-o' instead of '--option').""" def _segments(self) -> "Iterator[tuple[str, str] | Text]": assert self.argument is not None count, _ = self.argument.token_count() if count == 0: required_string = "flag required" only_got_string = "" elif count == 1: required_string = "requires an argument" only_got_string = "" else: required_string = f"requires {count} positional arguments" received_count = len(self.tokens_so_far) % count only_got_string = f" Only got {received_count}." if received_count else "" close_match: str | None = None if self.unused_tokens and self.argument.field_info.is_keyword: import difflib candidates = [x for x in self.unused_tokens if is_option_like(x)] matches = difflib.get_close_matches(self.argument.name, candidates, n=1, cutoff=0.6) if matches and matches[0] not in self.argument.names: close_match = matches[0] param_name = self.argument.name if self.keyword is not None: param_name = self.keyword elif self.argument.tokens: for token in reversed(self.argument.tokens): if token.keyword is not None: param_name = token.keyword break yield from super()._segments() if self.command_chain: yield 'Command "', "" yield " ".join(self.command_chain), STYLE_NAME yield '" parameter ', "" else: yield "Parameter ", "" yield param_name, STYLE_NAME yield f" {required_string}.{only_got_string}", "" if close_match is not None: yield " Did you mean ", "" yield self.argument.name, STYLE_SUGGESTION yield " instead of ", "" yield close_match, STYLE_OFFENDING_VALUE yield "?", "" if self.verbose: yield f" Parsed: {self.tokens_so_far}.", "" @define(kw_only=True) class ConsumeMultipleError(MissingArgumentError): """The number of values provided doesn't meet consume_multiple constraints.""" min_required: int = 0 max_allowed: int | None = None actual_count: int = 0 def _segments(self) -> "Iterator[tuple[str, str] | Text]": assert self.argument is not None param_name = self.keyword or self.argument.name if self.actual_count < self.min_required: constraint = f"requires at least {self.min_required}" else: constraint = f"accepts at most {self.max_allowed}" # Skip MissingArgumentError._segments; we want just the base verbose preamble. yield from CycloptsError._segments(self) if self.command_chain: yield 'Command "', "" yield " ".join(self.command_chain), STYLE_NAME yield '" parameter ', "" else: yield "Parameter ", "" yield param_name, STYLE_NAME yield f" {constraint} elements. Got {self.actual_count}.", "" @define(kw_only=True) class RequiresEqualsError(CycloptsError): """A long option requires ``=`` to assign a value (e.g., ``--option=value``).""" keyword: str | None = None """The keyword that was used (e.g., '--name').""" def _segments(self) -> "Iterator[tuple[str, str] | Text]": assert self.argument is not None param_name = self.keyword or self.argument.name yield from super()._segments() yield "Parameter ", "" yield param_name, STYLE_NAME yield " requires a value assigned with `=`. Use ", "" yield param_name, STYLE_NAME yield "=VALUE.", "" @define(kw_only=True) class RepeatArgumentError(CycloptsError): """The same parameter has erroneously been specified multiple times.""" token: "Token" """The repeated token.""" def _segments(self) -> "Iterator[tuple[str, str] | Text]": # Invariant: positional duplication is routed to UnusedCliTokensError by the binder, # so any token reaching this error path was matched by keyword. assert self.token.keyword is not None yield from super()._segments() yield "Parameter ", "" yield self.token.keyword, STYLE_NAME yield " specified multiple times.", "" @define(kw_only=True) class ArgumentOrderError(CycloptsError): """Cannot supply a POSITIONAL_OR_KEYWORD argument with a keyword, and then a later POSITIONAL_OR_KEYWORD argument positionally.""" token: str prior_positional_or_keyword_supplied_as_keyword_arguments: list["Argument"] def _segments(self) -> "Iterator[tuple[str, str] | Text]": assert self.argument is not None plural = len(self.prior_positional_or_keyword_supplied_as_keyword_arguments) > 1 display_name = next((x.keyword for x in self.argument.tokens if x.keyword), self.argument.name).lstrip("-") prior_list = [x.tokens[0].keyword for x in self.prior_positional_or_keyword_supplied_as_keyword_arguments] prior_display = prior_list[0] if len(prior_list) == 1 else prior_list yield from super()._segments() yield 'Cannot specify token "', "" yield self.token, STYLE_OFFENDING_VALUE yield '" positionally for parameter ', "" yield display_name, STYLE_NAME yield f" due to previously specified keyword{'s' if plural else ''} ", "" yield f"{prior_display}", STYLE_NAME yield ". ", "" yield f"{prior_display}", STYLE_NAME yield ' must either be passed positionally, or "', "" yield self.token, STYLE_OFFENDING_VALUE yield '" must be passed as a keyword to ', "" yield self.argument.name, STYLE_NAME yield ".", "" @define(kw_only=True) class MixedArgumentError(CycloptsError): """Cannot supply keywords and non-keywords to the same argument.""" def _segments(self) -> "Iterator[tuple[str, str] | Text]": assert self.argument is not None display_name = next((x.keyword for x in self.argument.tokens if x.keyword), self.argument.name) yield from super()._segments() yield "Cannot supply keyword & non-keyword arguments to ", "" yield display_name, STYLE_NAME yield ".", ""