From 075177ba1130828ab78f26c245f3b0866900c29b Mon Sep 17 00:00:00 2001 From: zigai Date: Fri, 16 Oct 2026 01:46:59 +0200 Subject: [PATCH 1/6] feat(cli)!: render native parse diagnostics --- .github/screenshots/parse-diagnostics.png | Bin 0 -> 29601 bytes docs/backends/argparse-wrapper.md | 2 +- docs/backends/plugins.md | 6 +- interfacy/__init__.py | 6 + interfacy/app.py | 4 + interfacy/backends/argparse/adapter.py | 24 +- interfacy/backends/argparse/errors.py | 50 +--- interfacy/backends/argparse/parser.py | 269 ++++++++++++++++-- interfacy/backends/base.py | 6 + interfacy/backends/click/adapter.py | 169 +++++++++-- interfacy/backends/click/commands.py | 38 +++ interfacy/backends/click/errors.py | 49 ++++ interfacy/backends/click/parser.py | 45 ++- interfacy/backends/click/types.py | 21 +- interfacy/cli/main.py | 18 +- interfacy/core/ancestor_options.py | 56 ++-- interfacy/core/compiler.py | 1 + interfacy/core/core.py | 28 +- interfacy/core/diagnostics.py | 125 ++++++++ interfacy/core/help.py | 14 +- interfacy/core/plugins.py | 38 +++ interfacy/core/recovery.py | 38 ++- interfacy/core/settings.py | 2 + interfacy/exceptions.py | 48 +++- interfacy/help/__init__.py | 4 + interfacy/help/diagnostics.py | 206 ++++++++++++++ interfacy/help/renderer.py | 29 +- interfacy/plugins/__init__.py | 2 + interfacy/plugins/base.py | 22 ++ interfacy/plugins/contexts.py | 17 ++ interfacy/runtime/invocation.py | 26 +- interfacy/runtime/policy.py | 26 ++ tests/conftest.py | 6 + .../backends/test_async_execution.py | 15 +- tests/contracts/backends/test_errors.py | 178 +++++++++++- tests/contracts/backends/test_help_errors.py | 135 +++++++++ tests/contracts/backends/test_parsing.py | 12 +- tests/contracts/backends/test_plugins.py | 110 ++++++- .../argparse/test_manual_parser.py | 37 ++- .../argparse/types/test_literal_aliases.py | 8 +- .../argparse/types/test_primitives.py | 7 +- tests/integration/cli/test_main.py | 2 +- tests/unit/core/test_facade.py | 10 +- 43 files changed, 1728 insertions(+), 181 deletions(-) create mode 100644 .github/screenshots/parse-diagnostics.png create mode 100644 interfacy/backends/click/errors.py create mode 100644 interfacy/core/diagnostics.py create mode 100644 interfacy/help/diagnostics.py diff --git a/.github/screenshots/parse-diagnostics.png b/.github/screenshots/parse-diagnostics.png new file mode 100644 index 0000000000000000000000000000000000000000..4820cb9ea2bb58983867a8b5e10a2c92cf006e47 GIT binary patch literal 29601 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 z

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 literal 0 HcmV?d00001 diff --git a/docs/backends/argparse-wrapper.md b/docs/backends/argparse-wrapper.md index 9332806..a50b90b 100644 --- a/docs/backends/argparse-wrapper.md +++ b/docs/backends/argparse-wrapper.md @@ -61,7 +61,7 @@ parser = ArgumentParser(help_flags=("--help", "-h")) ## Exiting -Like argparse, the parser prints help and exits with status 0, and reports usage errors on stderr and exits with status 2. Pass `sys_exit_enabled=False` to handle these yourself: help raises `InterfacyExit` and usage errors raise `UsageError`. Subparsers inherit the setting. +Help exits with status 0; native diagnostics go to stderr and exit with status 2. Customize their presentation with `diagnostic_layout=DiagnosticLayout(...)`. Pass `sys_exit_enabled=False` for silent `UsageError` exceptions with structured `.diagnostic` facts; help raises `InterfacyExit`. Subparsers inherit both settings. ```python from interfacy.exceptions import InterfacyExit, UsageError diff --git a/docs/backends/plugins.md b/docs/backends/plugins.md index 73cbac8..e0baa63 100644 --- a/docs/backends/plugins.md +++ b/docs/backends/plugins.md @@ -35,6 +35,8 @@ Portable plugins subclass `InterfacyPlugin` and can implement these hooks: | `after_parse()` | rewrite the parsed namespace | | `transform_schema()` | replace the built schema before backend materialization | | `transform_help()` | replace structured help sections before terminal rendering | +| `transform_diagnostic()` | replace diagnostic display content after recovery fails | +| `render_diagnostic()` | return final diagnostic text, or defer to the next renderer | | `wrap_execute()` | wrap command execution | | `recover_parse_failure()` | provide values or subcommands for recoverable parse failures | @@ -43,7 +45,9 @@ contexts expose only backend-neutral state and capabilities, such as the backend metadata, schema descriptors, raw arguments, or parsed namespaces. Metadata and namespace containers are copied into read-only mappings when the context is constructed. Plugins must return replacements from transformation hooks rather than reaching into parser -internals. +internals. Diagnostic transforms compose in registration order; the first renderer returning +`DiagnosticResult` handles output. Otherwise `diagnostic_layout` renders `DiagnosticContent`. +`DiagnosticHookContext` exposes failure facts separately. Embedded `invoke()` never renders. ## Help transformation diff --git a/interfacy/__init__.py b/interfacy/__init__.py index d71d018..5473236 100644 --- a/interfacy/__init__.py +++ b/interfacy/__init__.py @@ -14,10 +14,12 @@ from interfacy.exceptions import DuplicatePluginError as DuplicatePluginError from interfacy.exceptions import InterfacyError as InterfacyError from interfacy.exceptions import InvalidCommandError as InvalidCommandError + from interfacy.exceptions import ParseDiagnostic as ParseDiagnostic from interfacy.exceptions import PipeInputError as PipeInputError from interfacy.exceptions import ReservedFlagError as ReservedFlagError from interfacy.exceptions import UnsupportedParameterTypeError as UnsupportedParameterTypeError from interfacy.exceptions import UsageError as UsageError + from interfacy.help import DiagnosticLayout as DiagnosticLayout from interfacy.help import HelpRenderer as HelpRenderer from interfacy.help import HelpStyle as HelpStyle from interfacy.runtime.exit_codes import ExitCode as ExitCode @@ -31,6 +33,8 @@ "DuplicateCommandError": ("interfacy.exceptions", "DuplicateCommandError"), "DuplicatePluginError": ("interfacy.exceptions", "DuplicatePluginError"), "ExitCode": ("interfacy.runtime.exit_codes", "ExitCode"), + "DiagnosticLayout": ("interfacy.help", "DiagnosticLayout"), + "ParseDiagnostic": ("interfacy.exceptions", "ParseDiagnostic"), "HelpRenderer": ("interfacy.help", "HelpRenderer"), "HelpStyle": ("interfacy.help", "HelpStyle"), "Interfacy": ("interfacy.app", "Interfacy"), @@ -66,6 +70,7 @@ def __getattr__(name: str) -> Any: "BooleanMode", "CommandGroup", "ConfigurationError", + "DiagnosticLayout", "DuplicateCommandError", "DuplicatePluginError", "ExecutableFlag", @@ -76,6 +81,7 @@ def __getattr__(name: str) -> Any: "InterfacyError", "InvalidCommandError", "Param", + "ParseDiagnostic", "ParsedArguments", "PipeInputError", "ReservedFlagError", diff --git a/interfacy/app.py b/interfacy/app.py index f59ea1d..477bf5b 100644 --- a/interfacy/app.py +++ b/interfacy/app.py @@ -27,6 +27,7 @@ from interfacy.help import HelpLayout from interfacy.help.colors import InterfacyColors from interfacy.help.content import HelpRenderer +from interfacy.help.diagnostics import DiagnosticLayout from interfacy.naming import AbbreviationGenerator, FlagStrategy from interfacy.plugins import InterfacyPlugin from interfacy.runtime.invocation import CommandTarget, resolve_args @@ -51,6 +52,7 @@ class Interfacy: epilog: Text shown after generated help output. type_parser: Registry used to convert raw CLI strings into annotated types. help_layout: Layout configuration for generated help output. + diagnostic_layout: Presentation of parse failures, independently of help. backend: Parser implementation to use. Must be ``"argparse"`` or ``"click"``. help_colors: Color theme applied by the help layout. print_result: Print returned command values after execution. @@ -95,6 +97,7 @@ def __init__( backend: Backend = "argparse", help_colors: InterfacyColors | None = None, help_renderer: HelpRenderer | None = None, + diagnostic_layout: DiagnosticLayout | None = None, print_result: bool = False, tab_completion: bool = False, full_error_traceback: bool = False, @@ -174,6 +177,7 @@ def apply_setup( help_layout: HelpLayout | UnsetType | None = UNSET, help_colors: InterfacyColors | UnsetType | None = UNSET, help_renderer: HelpRenderer | UnsetType | None = UNSET, + diagnostic_layout: DiagnosticLayout | UnsetType | None = UNSET, type_parser: StrToTypeParser | UnsetType | None = UNSET, print_result: bool | UnsetType = UNSET, tab_completion: bool | UnsetType = UNSET, diff --git a/interfacy/backends/argparse/adapter.py b/interfacy/backends/argparse/adapter.py index 19119e4..b1d32d8 100644 --- a/interfacy/backends/argparse/adapter.py +++ b/interfacy/backends/argparse/adapter.py @@ -22,7 +22,8 @@ from interfacy.common.console import warn from interfacy.declarations.executable_flags import ExecutableAction, ExecutableFlag from interfacy.declarations.settings import BackendName -from interfacy.exceptions import ConfigurationError, UsageError +from interfacy.exceptions import ConfigurationError, ParseDiagnostic, UsageError +from interfacy.help.diagnostics import diagnostic_message from interfacy.schema.conversion import normalize_schema_values from interfacy.schema.nodes import ( COMMAND_KEY, @@ -48,6 +49,7 @@ def __init__( self._schema = schema self._help_pipeline = help_pipeline self._config = config + self._parsers: dict[tuple[str, ...], ArgumentParser] = {} self._native_parser = self._build(schema, relaxed=False) self._partial_parser: ArgumentParser | None = None self._attach_help(self._native_parser, ()) @@ -65,10 +67,14 @@ def parse(self, args: tuple[str, ...]) -> ParseOutcome: except ExecutableFlagTriggeredError as e: return ExecutableAction(e.flag) except (ArgparseParseError, argparse.ArgumentError, ValueError) as e: + diagnostic = e.diagnostic if isinstance(e, UsageError) else None + diagnostic = diagnostic or ParseDiagnostic(kind="usage") return BackendParseFailure( presentation=NativePresentation( kind="usage", - message=str(e), + message=diagnostic_message(diagnostic), + diagnostic=diagnostic, + command_path=diagnostic.command_path, exit_code=2, ), partial_namespace=self._best_effort_partial(args), @@ -79,9 +85,18 @@ def parse(self, args: tuple[str, ...]) -> ParseOutcome: return ParseResult(args=args, namespace=namespace, supplied=supplied) def present_error(self, presentation: NativePresentation) -> NoReturn: + diagnostic = presentation.diagnostic + parser = self._parsers.get(presentation.command_path, self._native_parser) + usage = None + if presentation.kind == "usage": + usage = parser.format_usage() + raise UsageError( presentation.message, - usage=self._native_parser.format_usage() if presentation.kind == "usage" else None, + usage=usage, + diagnostic=diagnostic, + program=parser.prog, + exit_code=presentation.exit_code, ) @staticmethod @@ -114,6 +129,8 @@ def _attach_help( command_path: tuple[str, ...], ) -> None: parser.set_help_pipeline(self._help_pipeline, command_path) + self._parsers[command_path] = parser + for action in parser._actions: if not isinstance(action, NestedSubParsersAction): continue @@ -133,6 +150,7 @@ def _new_parser(self) -> ArgumentParser: prog=self._config.program_name, fromfile_prefix_chars="@" if self._config.allow_args_from_file else None, help_layout=self._config.help_layout, + diagnostic_layout=self._config.diagnostic_layout, help_flags=self._config.help_flags, sys_exit_enabled=False, ) diff --git a/interfacy/backends/argparse/errors.py b/interfacy/backends/argparse/errors.py index 917c06e..57dcdf0 100644 --- a/interfacy/backends/argparse/errors.py +++ b/interfacy/backends/argparse/errors.py @@ -1,56 +1,10 @@ from __future__ import annotations -import argparse -import re -from collections.abc import Mapping, Sequence - from interfacy.exceptions import UsageError -MISSING_ARGUMENTS_MARKER = "the following arguments are required:" - class ArgparseParseError(UsageError): - """Internal recoverable argparse parse error.""" - - -def is_missing_subcommand_error( - message: str, - actions: Sequence[argparse.Action], - original_destinations: Mapping[str, str], -) -> bool: - """ - Return whether an argparse error reports a missing subcommand. - - Args: - message (str): Error message produced by argparse. - actions (Sequence[argparse.Action]): Actions registered on the failing parser. - original_destinations (Mapping[str, str]): Nested-to-original destination mapping. - """ - if MISSING_ARGUMENTS_MARKER not in message: - return False - - subparser_actions = [ - action for action in actions if isinstance(action, argparse._SubParsersAction) - ] - subparser_dests: set[str] = {action.dest for action in subparser_actions} - if not subparser_dests: - return False - - subparser_choices: set[str] = set() - for action in subparser_actions: - subparser_choices.update(action.choices.keys()) - - missing_part = message.split(MISSING_ARGUMENTS_MARKER, 1)[1].strip() - missing_names = [name.strip() for name in missing_part.split(",") if name.strip()] - denested_missing = [original_destinations.get(name, name) for name in missing_names] - denested_subparser_dests = {original_destinations.get(dest, dest) for dest in subparser_dests} - brace_choices: set[str] = set() - for grouped in re.findall(r"\{([^}]*)\}", missing_part): - brace_choices.update(choice.strip() for choice in grouped.split(",") if choice) - - return any(name in denested_subparser_dests for name in denested_missing) or bool( - brace_choices & subparser_choices - ) + """Internal recoverable argparse parse error with backend-neutral failure facts.""" -__all__ = ["MISSING_ARGUMENTS_MARKER", "ArgparseParseError", "is_missing_subcommand_error"] +__all__ = ["ArgparseParseError"] diff --git a/interfacy/backends/argparse/parser.py b/interfacy/backends/argparse/parser.py index 4375ce2..a13f5ec 100644 --- a/interfacy/backends/argparse/parser.py +++ b/interfacy/backends/argparse/parser.py @@ -5,6 +5,7 @@ from argparse import Namespace from collections.abc import Mapping, Sequence from copy import deepcopy +from dataclasses import dataclass, field from gettext import gettext from typing import TYPE_CHECKING, Any, NoReturn, TextIO @@ -12,7 +13,7 @@ callable_type_name, normalize_argument_kwargs, ) -from interfacy.backends.argparse.errors import ArgparseParseError, is_missing_subcommand_error +from interfacy.backends.argparse.errors import ArgparseParseError from interfacy.backends.argparse.help_schema import ( help_argument_from_actions, implicit_command, @@ -27,9 +28,16 @@ original_destination, ) from interfacy.common.debug import get_logger -from interfacy.exceptions import InterfacyExit, UsageError +from interfacy.common.terminal import get_terminal_width +from interfacy.exceptions import InterfacyExit, ParseDiagnostic, UsageError from interfacy.help import StandardLayout from interfacy.help.content import HelpRenderer +from interfacy.help.diagnostics import ( + DiagnosticLayout, + build_diagnostic_content, + diagnostic_message, + normalize_diagnostic_text, +) from interfacy.help.renderer import ( SchemaHelpRenderer, ) @@ -43,6 +51,13 @@ logger = get_logger(__name__) +@dataclass +class _ParseState: + depth: int = 0 + extras: dict[str, ParseDiagnostic] = field(default_factory=dict) + parsers: dict[tuple[str, ...], ArgumentParser] = field(default_factory=dict) + + class NestedSubParsersAction(argparse._SubParsersAction): # type: ignore[private-member-access] """ Subparser action that supports nested destination paths. @@ -80,6 +95,9 @@ def __init__( sys_exit_enabled: bool = True, supplied_dests: set[str] | None = None, destination_paths: dict[str, tuple[str, ...]] | None = None, + diagnostic_layout: DiagnosticLayout | None = None, + parse_state: _ParseState | None = None, + command_path: tuple[str, ...] = (), ) -> None: super().__init__( option_strings, @@ -99,6 +117,9 @@ def __init__( self._child_sys_exit_enabled = sys_exit_enabled self._child_supplied_dests = supplied_dests self._child_destination_paths = destination_paths + self._child_diagnostic_layout = diagnostic_layout + self._child_parse_state = parse_state + self._command_path = command_path def add_parser( # type: ignore[override] self, @@ -151,6 +172,9 @@ def add_parser( # type: ignore[override] kwargs.setdefault("sys_exit_enabled", self._child_sys_exit_enabled) kwargs.setdefault("supplied_dests", self._child_supplied_dests) kwargs.setdefault("destination_paths", self._child_destination_paths) + kwargs.setdefault("diagnostic_layout", self._child_diagnostic_layout) + kwargs.setdefault("parse_state", self._child_parse_state) + kwargs.setdefault("command_path", (*self._command_path, name)) nested_components = [*self.base_nest_path_components] if nested_components: nested_components.extend([SUBCOMMANDS_KEY, nest_dir or name]) @@ -237,6 +261,9 @@ def __init__( sys_exit_enabled: bool = True, supplied_dests: set[str] | None = None, destination_paths: dict[str, tuple[str, ...]] | None = None, + diagnostic_layout: DiagnosticLayout | None = None, + parse_state: _ParseState | None = None, + command_path: tuple[str, ...] = (), ) -> None: if parents is None: parents = [] @@ -290,7 +317,17 @@ def __init__( self._schema: ParserSchema | None = None self.help_renderer = help_renderer self._help_pipeline: HelpPipeline | None = None - self._help_command_path: tuple[str, ...] = () + self._help_command_path = command_path + self.diagnostic_layout = ( + deepcopy(diagnostic_layout) if diagnostic_layout else DiagnosticLayout() + ) + self._parse_state = parse_state if parse_state is not None else _ParseState() + self._parse_state.parsers[command_path] = self + self._optional_tokens: dict[str, bool] = {} + self._unknown_arguments: list[str] = [] + self._seen_actions: set[argparse.Action] = set() + self._in_native_parse = False + self._reading_arguments = False self._interfacy_raise_parse_errors = False self.sys_exit_enabled = sys_exit_enabled # Shared with every nested subparser; the owner clears it before each parse. @@ -319,7 +356,7 @@ def __init__( def format_help(self) -> str: """Render all generated and manual help through the structured pipeline.""" if self._help_pipeline is not None: - return self._help_pipeline.render(self._help_command_path) + return self._help_pipeline.render(self._help_command_path, program=self.prog) renderer = SchemaHelpRenderer( self._interfacy_help_layout, @@ -339,7 +376,11 @@ def set_help_pipeline( ) -> None: """Attach the core-owned structured help pipeline.""" self._help_pipeline = pipeline + if self._parse_state.parsers.get(self._help_command_path) is self: + del self._parse_state.parsers[self._help_command_path] + self._help_command_path = command_path + self._parse_state.parsers[command_path] = self def set_schema_command(self, command: Command | None) -> None: """ @@ -383,6 +424,9 @@ def add_subparsers(self, **kwargs: Any) -> NestedSubParsersAction: "sys_exit_enabled": self.sys_exit_enabled, "supplied_dests": self.supplied_dests, "destination_paths": self.destination_paths, + "diagnostic_layout": self.diagnostic_layout, + "parse_state": self._parse_state, + "command_path": self._help_command_path, } ) @@ -407,7 +451,27 @@ def parse_known_args( # type: ignore[override] if namespace is None: namespace = Namespace() - parsed_args, unknown_args = super().parse_known_args(args=args, namespace=namespace) + if self._parse_state.depth == 0: + self._parse_state.extras.clear() + + self._parse_state.depth += 1 + self._optional_tokens.clear() + self._unknown_arguments.clear() + try: + parsed_args, unknown_args = super().parse_known_args(args=args, namespace=namespace) + self._unknown_arguments = unknown_args + + for token in unknown_args: + optional = self._optional_tokens.get(token, False) + diagnostic = ParseDiagnostic( + kind="unknown_option" if optional else "unexpected_argument", + command_path=self._help_command_path, + argument=token.partition("=")[0] if optional else None, + choices=tuple(self._option_string_actions) if optional else (), + ) + self._parse_state.extras.setdefault(token, diagnostic) + finally: + self._parse_state.depth -= 1 logger.info( "Initial parse keys: %s, unknown count=%d", sorted(vars(parsed_args)), @@ -454,20 +518,165 @@ def exit(self, status: int = 0, message: str | None = None) -> NoReturn: raise UsageError((message or "").strip(), usage=self.format_usage()) - def error(self, message: str) -> NoReturn: - """Report a usage failure by exiting with status 2 or raising a structured error.""" - usage = self.format_usage() - if is_missing_subcommand_error(message, self._actions, self._original_destinations): - usage = self.format_help() + def _argument_label(self, action: argparse.Action) -> str: + if action.option_strings: + return next( + (flag for flag in action.option_strings if flag.startswith("--")), + action.option_strings[0], + ) + + metavar = action.metavar + + return metavar if isinstance(metavar, str) else self._original_dest_name(action.dest) + + def _missing_diagnostic(self) -> ParseDiagnostic | None: + if not self._in_native_parse or self._reading_arguments: + return None + + if any( + type(action).__module__ + not in ("argparse", __name__, "interfacy.backends.argparse.actions") + for action in self._actions + ): + return None + + missing = [ + action + for action in self._actions + if action.required and action not in self._seen_actions + ] + if not missing: + return None + + action = missing[0] + subcommand = isinstance(action, NestedSubParsersAction) + + return ParseDiagnostic( + kind="missing_subcommand" if subcommand else "missing_argument", + command_path=self._help_command_path, + argument=None if subcommand else self._argument_label(action), + choices=tuple(action.choices) if subcommand else (), + ) + + def _parse_known_args(self, *args: Any, **kwargs: Any) -> Any: + self._seen_actions.clear() + self._in_native_parse = True + diagnostic = None + try: + return super()._parse_known_args(*args, **kwargs) + except argparse.ArgumentError as e: + if e.argument_name is None: + diagnostic = self._missing_diagnostic() + finally: + self._in_native_parse = False + + return self._fail( + diagnostic or ParseDiagnostic(kind="usage", command_path=self._help_command_path) + ) + + def _read_args_from_files(self, arg_strings: list[str]) -> list[str]: + previous = self._reading_arguments + self._reading_arguments = True + try: + return super()._read_args_from_files(arg_strings) + except argparse.ArgumentError: + pass + finally: + self._reading_arguments = previous - if self._interfacy_raise_parse_errors: - raise ArgparseParseError(message, usage=usage) + return self._fail(ParseDiagnostic(kind="usage", command_path=self._help_command_path)) - if not self.sys_exit_enabled: - raise UsageError(message, usage=usage) + def _get_option_tuples(self, option_string: str) -> Any: + matches = super()._get_option_tuples(option_string) + if len(matches) > 1: + self._fail(ParseDiagnostic(kind="usage", command_path=self._help_command_path)) - self._print_message(f"{usage.rstrip()}\n", sys.stderr) - self.exit(2, f"{self.prog}: error: {message}\n") + return matches + + def _parse_optional(self, arg_string: str) -> Any: + result = super()._parse_optional(arg_string) + if result is not None: + match = result[0] if isinstance(result, list) else result + self._optional_tokens[arg_string] = match[0] is None + + return result + + def _match_argument(self, action: argparse.Action, arg_strings_pattern: str) -> int: + try: + return super()._match_argument(action, arg_strings_pattern) + except argparse.ArgumentError: + pass + return self._fail( + ParseDiagnostic( + kind="missing_value", + command_path=self._help_command_path, + argument=self._argument_label(action), + ) + ) + + def _check_value(self, action: argparse.Action, value: Any) -> None: + failed = False + try: + super()._check_value(action, value) + except argparse.ArgumentError: + failed = True + + if failed: + subcommand = isinstance(action, NestedSubParsersAction) + self._fail( + ParseDiagnostic( + kind="unknown_command" if subcommand else "invalid_choice", + command_path=self._help_command_path, + argument=None if subcommand else self._argument_label(action), + token=value if subcommand and isinstance(value, str) else None, + choices=tuple(str(choice) for choice in action.choices) + if action.choices + else (), + ) + ) + + def _fail(self, diagnostic: ParseDiagnostic) -> NoReturn: + parser = self._parse_state.parsers.get(diagnostic.command_path, self) + if parser is not self: + parser._fail(diagnostic) + + usage = ( + self.format_help() + if diagnostic.kind == "missing_subcommand" and self._help_pipeline is None + else self.format_usage() + ) + message = diagnostic_message(diagnostic) + if self._interfacy_raise_parse_errors or not self.sys_exit_enabled: + raise ArgparseParseError( + message, + usage=usage, + diagnostic=diagnostic, + program=self.prog, + ) + + content = build_diagnostic_content( + diagnostic, + program=self.prog, + help_flags=self.help_flags if self.add_help else (), + usage=usage, + ) + text = self.diagnostic_layout.render(content, terminal_width=get_terminal_width()) + self._print_message(normalize_diagnostic_text(text), sys.stderr) + self.exit(2) + + def error(self, message: str) -> NoReturn: + """Report native failures without exposing backend messages or rejected values.""" + diagnostic = self._missing_diagnostic() + if diagnostic is None and self._unknown_arguments: + diagnostic = self._parse_state.extras[self._unknown_arguments[0]] + + if diagnostic is None: + diagnostic = ParseDiagnostic( + kind="usage", + command_path=self._help_command_path, + message=message if not self._in_native_parse else "", + ) + self._fail(diagnostic) def _get_formatter(self, file: TextIO | None = None) -> argparse.HelpFormatter: # type: ignore[override] if sys.version_info >= (3, 15): @@ -579,21 +788,29 @@ def _edit_arguments(self, original_dest: str, **kwargs: Any) -> dict[str, Any]: def _get_value(self, action: argparse.Action, arg_string: str) -> Any: parse_func = self._registry_get("type", action.type, action.type) if not callable(parse_func): - raise argparse.ArgumentError(action, f"{parse_func!r} is not callable") + raise TypeError("Argument type must be callable") try: - result = parse_func(arg_string) + return parse_func(arg_string) + except (argparse.ArgumentTypeError, TypeError, ValueError, KeyError): + pass - except argparse.ArgumentTypeError as e: - raise argparse.ArgumentError(action, e.args[0]) from e - - except (TypeError, ValueError, KeyError) as e: - t_name = callable_type_name(parse_func, fallback="value") - raise argparse.ArgumentError(action, f"invalid {t_name} value: '{arg_string}'") from e - - return result + name = callable_type_name(parse_func, fallback="value") + expected = {"int": "an integer", "float": "a number", "str": "text"}.get( + name, "a valid value" + ) + return self._fail( + ParseDiagnostic( + kind="invalid_choice" if action.choices is not None else "invalid_type", + command_path=self._help_command_path, + argument=self._argument_label(action), + choices=tuple(str(choice) for choice in action.choices) if action.choices else (), + expected=expected, + ) + ) def _get_values(self, action: argparse.Action, arg_strings: list[str]) -> Any: + self._seen_actions.add(action) # argparse also calls this for absent optional positionals, with no strings. if action.option_strings or arg_strings: self.supplied_dests.add(action.dest) diff --git a/interfacy/backends/base.py b/interfacy/backends/base.py index a4cfcc9..cb060e7 100644 --- a/interfacy/backends/base.py +++ b/interfacy/backends/base.py @@ -10,7 +10,9 @@ from interfacy.common.resources import ResourceStack from interfacy.declarations.executable_flags import ExecutableAction, ExecutableFlag from interfacy.declarations.settings import BackendName +from interfacy.exceptions import ParseDiagnostic from interfacy.help import HelpLayout +from interfacy.help.diagnostics import DiagnosticLayout from interfacy.schema.nodes import ParserSchema NativeParserT_co = TypeVar("NativeParserT_co", covariant=True) @@ -28,6 +30,7 @@ class BackendConfig: tab_completion: bool = False allow_args_from_file: bool = True help_layout: HelpLayout | None = None + diagnostic_layout: DiagnosticLayout | None = None type_parser: StrToTypeParser | None = None def __post_init__(self) -> None: @@ -62,6 +65,7 @@ def __post_init__(self) -> None: class NativePresentation: kind: NativePresentationKind message: str + diagnostic: ParseDiagnostic | None = None command_path: tuple[str, ...] = () exit_code: int = 2 @@ -92,6 +96,8 @@ def render( self, command_path: tuple[str, ...], terminal_width: int | None = None, + *, + program: str | None = None, ) -> str: ... diff --git a/interfacy/backends/click/adapter.py b/interfacy/backends/click/adapter.py index ba254bc..ad5467c 100644 --- a/interfacy/backends/click/adapter.py +++ b/interfacy/backends/click/adapter.py @@ -1,7 +1,10 @@ from __future__ import annotations +import sys import warnings from collections.abc import Mapping, Sequence +from dataclasses import replace +from pathlib import Path from typing import Any, NoReturn import click @@ -25,11 +28,18 @@ InterfacyClickOption, InterfacyListOption, ) +from interfacy.backends.click.errors import ( + ClickParseContext, + ClickParseError, + ClickPresentation, + argument_label, +) from interfacy.backends.click.types import ChoiceParamType, ClickFuncParamType from interfacy.common.resources import ResourceStack from interfacy.declarations.executable_flags import ExecutableAction, ExecutableFlag from interfacy.declarations.settings import BackendName -from interfacy.exceptions import ConfigurationError, InterfacyExit, UsageError +from interfacy.exceptions import ConfigurationError, InterfacyExit, ParseDiagnostic, UsageError +from interfacy.help.diagnostics import diagnostic_message from interfacy.schema.conversion import normalize_schema_values from interfacy.schema.nodes import ( COMMAND_KEY, @@ -74,35 +84,130 @@ def parse(self, args: tuple[str, ...]) -> ParseOutcome: except click.exceptions.Exit as e: raise InterfacyExit(e.exit_code) from e except click.UsageError as e: + diagnostic = self._diagnostic(e) + presentation = ClickPresentation( + kind="usage", + message=diagnostic_message(diagnostic), + diagnostic=diagnostic, + command_path=diagnostic.command_path, + exit_code=e.exit_code, + context=e.ctx, + ) partial = self._partial_namespace(args, resources) - message = e.format_message() return BackendParseFailure( - presentation=NativePresentation( - kind="usage", - message=message, - exit_code=e.exit_code, - ), + presentation=presentation, partial_namespace=partial, resources=resources.pop_all(), ) - except ValueError as e: + except ValueError: partial = self._partial_namespace(args, resources) + diagnostic = ParseDiagnostic(kind="usage") return BackendParseFailure( presentation=NativePresentation( kind="usage", - message=str(e), + message=diagnostic_message(diagnostic), + diagnostic=diagnostic, exit_code=2, ), partial_namespace=partial, resources=resources.pop_all(), ) + @staticmethod + def _diagnostic(error: click.UsageError) -> ParseDiagnostic: + context = error.ctx + command = context.command if context is not None else None + path = ( + command.interfacy_help_command_path + if isinstance(command, (InterfacyClickCommand, InterfacyClickGroup)) + else () + ) + parameter = error.param if isinstance(error, click.BadParameter) else None + label = argument_label(parameter) if parameter is not None else None + schema_argument = ( + parameter.interfacy_argument + if isinstance(parameter, (InterfacyClickOption, InterfacyClickArgument)) + else None + ) + if schema_argument is not None and schema_argument.kind is ArgumentKind.POSITIONAL: + label = ( + schema_argument.metavar or schema_argument.display_name.replace("_", "-").upper() + ) + + if isinstance(error, ClickParseError): + diagnostic = error.diagnostic + elif isinstance(error, click.exceptions.NoArgsIsHelpError): + diagnostic = ParseDiagnostic( + kind="missing_subcommand", + choices=tuple(command.commands) if isinstance(command, click.Group) else (), + ) + elif isinstance(error, click.NoSuchOption): + diagnostic = ParseDiagnostic(kind="unknown_option", argument=error.option_name) + elif isinstance(error, click.MissingParameter): + diagnostic = ParseDiagnostic(kind="missing_argument", argument=label) + elif isinstance(error, click.BadParameter): + parameter_type = parameter.type if parameter is not None else None + is_choice = isinstance(parameter_type, (click.Choice, ChoiceParamType)) + diagnostic = ParseDiagnostic( + kind="invalid_choice" if is_choice else "invalid_type", + argument=label, + choices=( + tuple(str(choice) for choice in parameter_type.choices) + if isinstance(parameter_type, (click.Choice, ChoiceParamType)) + else () + ), + ) + elif isinstance(error, click.BadOptionUsage): + diagnostic = ParseDiagnostic(kind="usage", argument=error.option_name) + else: + diagnostic = ParseDiagnostic(kind="usage") + + expected = diagnostic.expected + if diagnostic.kind == "invalid_type" and schema_argument is not None: + expected_types: dict[type[Any] | None, str] = { + int: "an integer", + float: "a number", + str: "text", + } + expected = expected_types.get(schema_argument.type, "a valid value") + + return replace( + diagnostic, + command_path=path, + argument=label if parameter is not None else diagnostic.argument, + expected=expected, + ) + def present_error(self, presentation: NativePresentation) -> NoReturn: + context = presentation.context if isinstance(presentation, ClickPresentation) else None + if context is None: + command = self._native_parser + context = command.context_class( + command, info_name=self._config.program_name or Path(sys.argv[0]).name + ) + for segment in presentation.command_path: + if not isinstance(command, click.Group): + break + + child = command.get_command(context, segment) + if child is None: + break + + command = child + context = command.context_class(command, info_name=segment, parent=context) + usage = None + diagnostic = presentation.diagnostic if presentation.kind == "usage": - usage = self._native_parser.get_usage(click.Context(self._native_parser)) - - raise UsageError(presentation.message, usage=usage) + usage = context.get_usage() + + raise UsageError( + presentation.message, + usage=usage, + diagnostic=diagnostic, + program=context.command_path, + exit_code=presentation.exit_code, + ) def _attach_help( self, @@ -231,6 +336,9 @@ def _build_params( used: set[str] = set() for argument in arguments: param, suppress = self._make_param(argument, used, relaxed=relaxed) + if isinstance(param, (InterfacyClickOption, InterfacyClickArgument)): + param.interfacy_argument = argument + params.append(param) if param.name is not None: @@ -389,7 +497,9 @@ def _parse_root( partial: bool, ) -> tuple[dict[str, Any], dict[str, Any], list[str]]: """Return the namespace, the part of it given on the command line, and leftovers.""" - context = self._make_context(root, root.name or "main", args, resources) + context = self._make_context( + root, self._config.program_name or Path(sys.argv[0]).name, args, resources + ) if not isinstance(root, (InterfacyClickCommand, InterfacyClickGroup)): raise ConfigurationError(f"Unexpected Click root: {type(root)!r}") @@ -429,8 +539,16 @@ def _resolve_child_context( partial: bool, ) -> tuple[str, InterfacyClickCommand | InterfacyClickGroup, click.Context] | None: remaining = self._remaining(context) - if not remaining and partial: - return None + if not remaining: + if partial or group.invoke_without_command: + return None + + raise ClickParseError( + ParseDiagnostic( + kind="missing_subcommand", choices=tuple(group.list_commands(context)) + ), + ctx=context, + ) name, command, command_args = group.resolve_command(context, remaining) if command is None or not isinstance(command, (InterfacyClickCommand, InterfacyClickGroup)): @@ -505,10 +623,25 @@ def _make_context( settings["resilient_parsing"] = False context = command.context_class(command, info_name=name, parent=parent, **settings) resources.callback(context.close) - with context.scope(cleanup=False): - command.parse_args(context, args) + try: + with context.scope(cleanup=False): + command.parse_args(context, args) + except click.UsageError as e: + if e.ctx is None: + e.ctx = context + + if not ( + type(e) is click.UsageError + and isinstance(context, ClickParseContext) + and context.interfacy_has_extra_args + and not context.allow_extra_args + and not context.resilient_parsing + ): + raise + else: + return context - return context + raise ClickParseError(ParseDiagnostic(kind="unexpected_argument"), ctx=context) @staticmethod def _remaining(context: click.Context) -> list[str]: diff --git a/interfacy/backends/click/commands.py b/interfacy/backends/click/commands.py index 6c1c0dc..fb468e9 100644 --- a/interfacy/backends/click/commands.py +++ b/interfacy/backends/click/commands.py @@ -6,8 +6,10 @@ import click from interfacy.backends.base import HelpPipeline +from interfacy.backends.click.errors import ClickParseContext, ClickParseError from interfacy.backends.click.parser import InterfacyOptionParser from interfacy.declarations.params import BooleanMode +from interfacy.exceptions import ParseDiagnostic from interfacy.help import HelpLayout, StandardLayout from interfacy.help.content import HelpRenderer from interfacy.help.renderer import SchemaHelpRenderer @@ -26,6 +28,8 @@ class InterfacyClickOption(click.Option): """Normalize Click option help records to omit metavar suffixes.""" + interfacy_argument: Argument | None = None + def get_help_record(self, ctx: click.Context) -> tuple[str, str] | None: """ Return a cleaned help-record tuple for one option. @@ -110,6 +114,8 @@ def type_cast_value(self, ctx: click.Context, value: Any) -> Any: class InterfacyClickArgument(click.Argument): """Carry argument help text and normalize argument help-row names.""" + interfacy_argument: Argument | None = None + def __init__( self, param_decls: Sequence[str], @@ -168,6 +174,7 @@ def get_help(self, ctx: click.Context) -> str: return self.interfacy_help_pipeline.render( self.interfacy_help_command_path, ctx.terminal_width, + program=ctx.command_path, ) layout = self.interfacy_help_layout or StandardLayout() @@ -305,6 +312,8 @@ def _argument_from_click_parameter(parameter: click.Parameter) -> Argument: class InterfacyClickCommand(HelpMixin, click.Command): """Render command help with Interfacy schema-aware formatting.""" + context_class = ClickParseContext + def __init__( self, *args: Any, @@ -333,6 +342,8 @@ def make_parser(self, ctx: click.Context) -> InterfacyOptionParser: class InterfacyClickGroup(HelpMixin, click.Group): """Resolve group aliases and render group help with schema metadata.""" + context_class = ClickParseContext + def __init__( self, *args: Any, @@ -357,6 +368,33 @@ def make_parser(self, ctx: click.Context) -> InterfacyOptionParser: return parser + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + if not args and self.no_args_is_help and not ctx.resilient_parsing: + raise ClickParseError( + ParseDiagnostic(kind="missing_subcommand", choices=tuple(self.list_commands(ctx))), + ctx=ctx, + ) + + return super().parse_args(ctx, args) + + def resolve_command( + self, ctx: click.Context, args: list[str] + ) -> tuple[str | None, click.Command | None, list[str]]: + try: + return super().resolve_command(ctx, args) + except click.UsageError as e: + if type(e) is not click.UsageError: + raise + + raise ClickParseError( + ParseDiagnostic( + kind="unknown_command", + token=args[0], + choices=tuple(self.list_commands(ctx)), + ), + ctx=ctx, + ) + def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: """ Resolve a subcommand by canonical name first, then by Interfacy aliases. diff --git a/interfacy/backends/click/errors.py b/interfacy/backends/click/errors.py new file mode 100644 index 0000000..7ae9717 --- /dev/null +++ b/interfacy/backends/click/errors.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +import click + +from interfacy.backends.base import NativePresentation +from interfacy.exceptions import ParseDiagnostic +from interfacy.help.diagnostics import diagnostic_message + + +class ClickParseError(click.BadParameter): + """Carry safe failure facts from the exact Click operation that rejected input.""" + + def __init__( + self, + diagnostic: ParseDiagnostic, + *, + ctx: click.Context | None = None, + param: click.Parameter | None = None, + ) -> None: + self.diagnostic = diagnostic + super().__init__(diagnostic_message(diagnostic), ctx=ctx, param=param) + + +class ClickParseContext(click.Context): + """Keep parser-produced state local to a single command context.""" + + interfacy_has_extra_args: bool = False + + +@dataclass(slots=True, kw_only=True) +class ClickPresentation(NativePresentation): + """Retain the failing invocation context without retaining its exception.""" + + context: click.Context | None = field(default=None, repr=False, compare=False) + + +def argument_label(parameter: click.Parameter) -> str: + if isinstance(parameter, click.Option): + return next( + (flag for flag in parameter.opts if flag.startswith("--")), + parameter.opts[0] if parameter.opts else parameter.name or "argument", + ) + + return (parameter.name or "argument").replace("_", "-") + + +__all__ = ["ClickParseContext", "ClickParseError", "ClickPresentation", "argument_label"] diff --git a/interfacy/backends/click/parser.py b/interfacy/backends/click/parser.py index 5c33f36..29b284d 100644 --- a/interfacy/backends/click/parser.py +++ b/interfacy/backends/click/parser.py @@ -6,9 +6,13 @@ import click import click.parser as click_parser +from interfacy.backends.click.errors import ClickParseContext, ClickParseError, argument_label +from interfacy.exceptions import ParseDiagnostic + class OptionLike(Protocol): nargs: int + obj: click.Parameter class ParsingStateLike(Protocol): @@ -25,12 +29,43 @@ def _click_parser_symbol(name: str) -> Any: _BaseOptionParser: type[Any] = _click_parser_symbol("_OptionParser") +_BaseArgument: type[Any] = _click_parser_symbol("_Argument") _normalize_opt: Callable[[str, Any], str] = _click_parser_symbol("_normalize_opt") +class _InterfacyParserArgument(_BaseArgument): + def process(self, value: Any, state: Any) -> None: + try: + super().process(value, state) + except click.BadArgumentUsage: + pass + else: + return + + raise ClickParseError( + ParseDiagnostic(kind="missing_value", argument=argument_label(self.obj)), + param=self.obj, + ) + + class InterfacyOptionParser(_BaseOptionParser): """Parse Click options while supporting varargs option values.""" + def add_argument(self, obj: click.Argument, dest: str | None, nargs: int = 1) -> None: + self._args.append(_InterfacyParserArgument(obj, dest=dest, nargs=nargs)) + + def parse_args( + self, args: list[str] + ) -> tuple[dict[str, Any], list[str], list[click.Parameter]]: + if isinstance(self.ctx, ClickParseContext): + self.ctx.interfacy_has_extra_args = False + + result = super().parse_args(args) + if isinstance(self.ctx, ClickParseContext): + self.ctx.interfacy_has_extra_args = bool(result[1]) + + return result + def _token_looks_like_option(self, arg_text: str) -> bool: if arg_text == "--": return True @@ -92,7 +127,15 @@ def _get_value_from_state( state: ParsingStateLike, ) -> Any: if option.nargs != -1: - return super()._get_value_from_state(option_name, option, state) + try: + return super()._get_value_from_state(option_name, option, state) + except click.BadOptionUsage: + pass + raise ClickParseError( + ParseDiagnostic(kind="missing_value", argument=option_name), + ctx=self.ctx, + param=option.obj, + ) collected: list[str] = [] while state.rargs: diff --git a/interfacy/backends/click/types.py b/interfacy/backends/click/types.py index 4883c90..e1b4abe 100644 --- a/interfacy/backends/click/types.py +++ b/interfacy/backends/click/types.py @@ -5,6 +5,9 @@ import click +from interfacy.backends.click.errors import ClickParseError, argument_label +from interfacy.exceptions import ParseDiagnostic + class ClickFuncParamType(click.types.FuncParamType): """Wrap a callable parser as a Click parameter type.""" @@ -17,8 +20,13 @@ def __init__(self, func: Callable[[Any], Any], name: str | None = None) -> None: def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> Any: try: return self.func(value) - except (ValueError, TypeError, KeyError) as e: - self.fail(f"invalid value for configured type parser: {e}", param, ctx) + except (ValueError, TypeError, KeyError): + pass + raise ClickParseError( + ParseDiagnostic(kind="invalid_type", argument=argument_label(param) if param else None), + ctx=ctx, + param=param, + ) class ChoiceParamType(click.ParamType): @@ -56,9 +64,12 @@ def convert(self, value: str, param: click.Parameter | None, ctx: click.Context converted = value allowed = self._parsed_choices if self._parsed_choices is not None else self.choices if converted not in allowed: - choices_repr = ", ".join(repr(choice) for choice in self.choices) - raise click.BadParameter( - f"invalid choice: {value!r} (choose from {choices_repr})", + raise ClickParseError( + ParseDiagnostic( + kind="invalid_choice", + argument=argument_label(param) if param else None, + choices=tuple(str(choice) for choice in self.choices), + ), ctx=ctx, param=param, ) diff --git a/interfacy/cli/main.py b/interfacy/cli/main.py index e748875..a1220cb 100644 --- a/interfacy/cli/main.py +++ b/interfacy/cli/main.py @@ -12,9 +12,11 @@ from interfacy.cli.config import apply_config_defaults, get_default_config_paths, load_config from interfacy.cli.targets import resolve_target_with_module from interfacy.common.console import error, warn +from interfacy.common.terminal import get_terminal_width from interfacy.declarations.groups import CommandGroup -from interfacy.exceptions import InterfacyExit, UsageError +from interfacy.exceptions import InterfacyExit, ParseDiagnostic, UsageError from interfacy.help import HelpLayout, StandardLayout +from interfacy.help.diagnostics import build_diagnostic_content, normalize_diagnostic_text from interfacy.runtime.exit_codes import ExitCode @@ -57,6 +59,7 @@ def build_parser(settings: dict[str, Any]) -> ArgumentParser: description=description, epilog=epilog, help_layout=_resolve_help_layout(settings), + diagnostic_layout=settings.get("diagnostic_layout"), help_flags=settings.get("help_flags") or ("--help",), sys_exit_enabled=False, ) @@ -117,6 +120,7 @@ def build_runner_kwargs(settings: dict[str, Any]) -> dict[str, Any]: kwargs["help_colors"] = help_colors for key in ( + "diagnostic_layout", "flag_strategy", "abbreviation_gen", "abbreviation_max_generated_len", @@ -187,9 +191,15 @@ def _validate_entrypoint_target(target: Any) -> None: def _render_usage_error(parser: ArgumentParser, exc: UsageError) -> None: - usage = exc.usage or parser.format_usage() - print(usage.rstrip(), file=sys.stderr) - error(f"{parser.prog}: error: {exc}") + diagnostic = exc.diagnostic or ParseDiagnostic(kind="usage", message=str(exc)) + content = build_diagnostic_content( + diagnostic, + program=exc.program or parser.prog, + help_flags=parser.help_flags if parser.add_help else (), + usage=exc.usage or parser.format_usage(), + ) + rendered = parser.diagnostic_layout.render(content, terminal_width=get_terminal_width()) + error(normalize_diagnostic_text(rendered).rstrip()) def main(argv: Sequence[str] | None = None) -> ExitCode: diff --git a/interfacy/core/ancestor_options.py b/interfacy/core/ancestor_options.py index 26e6ff1..69518fc 100644 --- a/interfacy/core/ancestor_options.py +++ b/interfacy/core/ancestor_options.py @@ -7,6 +7,8 @@ from strto import StrToTypeParser +from interfacy.exceptions import ParseDiagnostic +from interfacy.help.diagnostics import diagnostic_message from interfacy.schema.nodes import Argument, Command, ParserSchema from interfacy.schema.values import plan_requires_post_conversion @@ -14,16 +16,9 @@ class InterspersedOptionValueError(ValueError): """Raised when a normalized interspersed option value fails validation or conversion.""" - def __init__( - self, - argument: Argument, - raw_value: str, - original: TypeError | ValueError, - ) -> None: - self.argument = argument - self.raw_value = raw_value - self.original = original - super().__init__(str(original)) + def __init__(self, diagnostic: ParseDiagnostic) -> None: + self.diagnostic = diagnostic + super().__init__(diagnostic_message(diagnostic)) @dataclass(frozen=True, slots=True) @@ -116,7 +111,9 @@ def resolve_values(self, *, type_parser: StrToTypeParser) -> AncestorOptionValue ( command_path, argument.name, - self._parse_option_values(argument, raw_values, type_parser=type_parser), + self._parse_option_values( + argument, raw_values, type_parser=type_parser, command_path=command_path + ), ) for command_path, argument, raw_values in pending ) @@ -259,29 +256,46 @@ def _option_group_raw_values( @staticmethod def _parse_option_values( - argument: Argument, raw_values: Sequence[str], *, type_parser: StrToTypeParser + argument: Argument, + raw_values: Sequence[str], + *, + type_parser: StrToTypeParser, + command_path: tuple[str, ...], ) -> Any: - minimum_values = argument.cardinality.minimum_values - if len(raw_values) < minimum_values: - error = ValueError(f"expected at least {minimum_values} value(s)") - raise InterspersedOptionValueError(argument, " ".join(raw_values), error) + label = next( + (flag for flag in argument.flags if flag.startswith("--")), argument.display_name + ) + if len(raw_values) < argument.cardinality.minimum_values: + raise InterspersedOptionValueError( + ParseDiagnostic(kind="missing_value", argument=label, command_path=command_path) + ) + diagnostic = ParseDiagnostic( + kind="invalid_choice" if argument.choices is not None else "invalid_type", + argument=label, + command_path=command_path, + choices=tuple(str(choice) for choice in argument.choices or ()), + ) plan = argument.value_plan if plan is not None and plan_requires_post_conversion(plan, required=argument.required): try: return plan.convert(raw_values, type_parser=type_parser) - except (TypeError, ValueError) as e: - raise InterspersedOptionValueError(argument, " ".join(raw_values), e) from e + except (TypeError, ValueError): + pass + raise InterspersedOptionValueError(diagnostic) parse = argument.parser values: list[Any] = [] for raw_value in raw_values: try: value = parse(raw_value) if parse is not None else raw_value - except (TypeError, ValueError) as e: - raise InterspersedOptionValueError(argument, raw_value, e) from e + except (TypeError, ValueError): + pass + else: + values.append(value) + continue - values.append(value) + raise InterspersedOptionValueError(diagnostic) if argument.value_shape.name == "LIST": return values diff --git a/interfacy/core/compiler.py b/interfacy/core/compiler.py index f39d8fa..17638df 100644 --- a/interfacy/core/compiler.py +++ b/interfacy/core/compiler.py @@ -102,6 +102,7 @@ def backend_config( tab_completion=settings.tab_completion, allow_args_from_file=settings.allow_args_from_file, help_layout=help_layout, + diagnostic_layout=settings.diagnostic_layout, type_parser=type_parser, ) diff --git a/interfacy/core/core.py b/interfacy/core/core.py index 1857bdf..43b6f3a 100644 --- a/interfacy/core/core.py +++ b/interfacy/core/core.py @@ -24,6 +24,7 @@ generation_key, schema_build_context, ) +from interfacy.core.diagnostics import render_diagnostic from interfacy.core.help import ( SchemaHelpPipeline, configure_help_layout, @@ -43,8 +44,13 @@ from interfacy.declarations.pipes import PipeTargets from interfacy.declarations.settings import BackendName from interfacy.declarations.sorting import HelpOptionSortRule, HelpSubcommandSortRule -from interfacy.exceptions import ConfigurationError, DuplicateCommandError, InterfacyExit -from interfacy.help import HelpLayout, StandardLayout +from interfacy.exceptions import ( + ConfigurationError, + DuplicateCommandError, + InterfacyExit, + UsageError, +) +from interfacy.help import DiagnosticLayout, HelpLayout, StandardLayout from interfacy.introspection.type_parsers import build_default_type_parser from interfacy.naming import ( AbbreviationGenerator, @@ -420,7 +426,7 @@ def parse(self, args: tuple[str, ...]) -> InvocationInput: session.present_error( outcome.presentation if isinstance(outcome, BackendParseFailure) - else usage_presentation(str(e)) + else usage_presentation(str(e), diagnostic=e.diagnostic) ) if supplied is not None: @@ -537,6 +543,7 @@ def render_help( schema: ParserSchema, command_path: tuple[str, ...], terminal_width: int | None = None, + program: str | None = None, ) -> str: return render_help( schema, @@ -547,6 +554,20 @@ def render_help( plugin_manager=self.plugin_manager, help_layout=self.help_layout, help_renderer=self.settings.help_renderer, + program=program, + ) + + def render_diagnostic(self, failure: UsageError) -> str: + schema = self.last_schema or self.build_parser_schema() + return render_diagnostic( + failure, + schema, + backend=self.backend, + metadata=self.metadata, + plugin_manager=self.plugin_manager, + layout=self.settings.diagnostic_layout or DiagnosticLayout(), + help_layout=self.help_layout, + help_renderer=self.settings.help_renderer, ) def _session(self, schema: ParserSchema | None = None) -> BackendSession[object]: @@ -610,6 +631,7 @@ def _install(self, settings: CoreSettings, components: CoreComponents) -> None: self.abbreviation_gen = components.abbreviation_gen self.type_parser = components.type_parser self.runtime_policy = components.runtime_policy + self.runtime_policy.diagnostic_renderer = self.render_diagnostic self._runtime = InvocationRuntime(components.runtime_policy) def _schema_build_context(self) -> SchemaBuildContext: diff --git a/interfacy/core/diagnostics.py b/interfacy/core/diagnostics.py new file mode 100644 index 0000000..a848d89 --- /dev/null +++ b/interfacy/core/diagnostics.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import sys +from collections.abc import Mapping +from dataclasses import replace +from pathlib import Path +from typing import Any + +from interfacy.common.terminal import get_terminal_width +from interfacy.core.help import command_for_path, render_help +from interfacy.core.plugins import PluginManager +from interfacy.core.recovery import subcommands_at_path +from interfacy.declarations.settings import BackendName +from interfacy.exceptions import ParseDiagnostic, UsageError +from interfacy.help.content import HelpRenderer +from interfacy.help.diagnostics import ( + DiagnosticLayout, + build_diagnostic_content, + normalize_diagnostic_text, +) +from interfacy.help.layouts import HelpLayout +from interfacy.help.renderer import SchemaHelpRenderer +from interfacy.plugins import DiagnosticHookContext, SchemaDescriptor +from interfacy.schema.nodes import Command, ParserSchema, find_command + + +def render_diagnostic( + failure: UsageError, + schema: ParserSchema, + *, + backend: BackendName, + metadata: Mapping[str, Any], + plugin_manager: PluginManager, + layout: DiagnosticLayout, + help_layout: HelpLayout, + help_renderer: HelpRenderer | None, +) -> str: + diagnostic = failure.diagnostic or ParseDiagnostic(kind="usage", message=str(failure)) + commands = subcommands_at_path(schema, diagnostic.command_path) + if diagnostic.kind == "unknown_command": + diagnostic = replace( + diagnostic, + choices=tuple( + name + for command in commands.values() + for name in (command.cli_name, *command.aliases) + ), + ) + elif diagnostic.kind == "unknown_option": + diagnostic = replace(diagnostic, choices=option_flags(schema, diagnostic.command_path)) + + program = failure.program or Path(sys.argv[0]).name + width = get_terminal_width() + context = DiagnosticHookContext( + backend=backend, + metadata=metadata, + program=program, + terminal_width=width, + diagnostic=diagnostic, + schema=SchemaDescriptor.from_schema(schema), + ) + + if diagnostic.kind == "missing_subcommand": + usage = render_help( + schema, + diagnostic.command_path, + width, + backend=backend, + metadata=metadata, + plugin_manager=plugin_manager, + help_layout=help_layout, + help_renderer=help_renderer, + program=program, + ) + else: + usage = SchemaHelpRenderer( + help_layout, terminal_width=width, help_flags=schema.help_flags + ).render_usage(schema, program, command=command_for_path(schema, diagnostic.command_path)) + content = build_diagnostic_content( + diagnostic, + program=program, + help_flags=schema.help_flags, + usage=usage, + ) + if diagnostic.kind == "unknown_command" and not content.suggestions: + content = replace(content, choices=tuple(command.cli_name for command in commands.values())) + transformed = plugin_manager.transform_diagnostic(context, content) + result = plugin_manager.render_diagnostic(context, transformed) + text = result.text if result is not None else layout.render(transformed, terminal_width=width) + + return normalize_diagnostic_text(text) + + +def option_flags(schema: ParserSchema, command_path: tuple[str, ...]) -> tuple[str, ...]: + flags = [*schema.help_flags] + flags.extend(flag for action in schema.executable_flags for flag in action.flags) + chain: list[Command] = [] + commands = schema.commands + if len(commands) == 1 and not schema.is_multi_command: + command = next(iter(commands.values())) + if command.command_type != "group": + chain.append(command) + commands = command.subcommands or {} + + for segment in command_path: + command = find_command(commands, segment) + if command is None: + break + + chain.append(command) + commands = command.subcommands or {} + + for command in chain: + for argument in (*command.initializer, *command.arguments): + flags.extend(argument.flags) + if argument.boolean_behavior is not None: + flags.extend(argument.boolean_behavior.positive_flags) + flags.extend(argument.boolean_behavior.negative_flags) + + flags.extend(flag for action in command.executable_flags for flag in action.flags) + + return tuple(dict.fromkeys(flags)) + + +__all__ = ["option_flags", "render_diagnostic"] diff --git a/interfacy/core/help.py b/interfacy/core/help.py index ec580a1..351c8b0 100644 --- a/interfacy/core/help.py +++ b/interfacy/core/help.py @@ -29,7 +29,7 @@ from interfacy.plugins import HelpHookContext, SchemaDescriptor from interfacy.schema.nodes import Command, ParserSchema, find_command -RenderHelp = Callable[[ParserSchema, tuple[str, ...], int | None], str] +RenderHelp = Callable[[ParserSchema, tuple[str, ...], int | None, str | None], str] class SchemaHelpPipeline(HelpPipeline): @@ -43,8 +43,10 @@ def render( self, command_path: tuple[str, ...], terminal_width: int | None = None, + *, + program: str | None = None, ) -> str: - return self._render(self._schema, command_path, terminal_width) + return self._render(self._schema, command_path, terminal_width, program) def effective_option_sort( @@ -122,6 +124,11 @@ def command_for_path( return None commands = schema.commands + if len(commands) == 1 and not schema.is_multi_command: + root = next(iter(commands.values())) + if root.command_type != "group": + commands = root.subcommands or {} + current: Command | None = None for segment in command_path: current = find_command(commands, segment) @@ -143,9 +150,10 @@ def render_help( plugin_manager: PluginManager, help_layout: HelpLayout, help_renderer: HelpRenderer | None, + program: str | None = None, ) -> str: """Render help for ``command_path`` through help plugins and the configured renderer.""" - program = " ".join(command_path) or "main" + program = program or " ".join(command_path) or "main" semantic_path = command_path if not semantic_path and len(schema.commands) == 1 and not schema.is_multi_command: semantic_path = (next(iter(schema.commands.values())).canonical_name,) diff --git a/interfacy/core/plugins.py b/interfacy/core/plugins.py index e04c954..15546fe 100644 --- a/interfacy/core/plugins.py +++ b/interfacy/core/plugins.py @@ -6,6 +6,7 @@ from interfacy.exceptions import ConfigurationError, DuplicatePluginError from interfacy.help.content import HelpContent, HelpResult +from interfacy.help.diagnostics import DiagnosticContent, DiagnosticResult from interfacy.plugins import ( AbortRecovery, AfterParseContext, @@ -13,6 +14,7 @@ BackendPluginContext, BeforeParseContext, ConfigureContext, + DiagnosticHookContext, ExecuteContext, HelpHookContext, InterfacyPlugin, @@ -167,6 +169,42 @@ def render_help( return None + def transform_diagnostic( + self, + context: DiagnosticHookContext, + content: DiagnosticContent, + ) -> DiagnosticContent: + current = content + for plugin in self.plugins: + result = plugin.transform_diagnostic(context, current) + if not isinstance(result, DiagnosticContent): + self._invalid_result( + plugin.plugin_name, "transform_diagnostic", "DiagnosticContent" + ) + + current = result + + return current + + def render_diagnostic( + self, + context: DiagnosticHookContext, + content: DiagnosticContent, + ) -> DiagnosticResult | None: + for plugin in self.plugins: + result = plugin.render_diagnostic(context, content) + if result is None: + continue + + if not isinstance(result, DiagnosticResult): + self._invalid_result( + plugin.plugin_name, "render_diagnostic", "DiagnosticResult | None" + ) + + return result + + return None + def recover( self, context: ParseFailureContext, diff --git a/interfacy/core/recovery.py b/interfacy/core/recovery.py index 7041b60..96d5947 100644 --- a/interfacy/core/recovery.py +++ b/interfacy/core/recovery.py @@ -1,13 +1,14 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import replace from typing import Any from interfacy.backends.base import BackendParseFailure, BackendSession, NativePresentation from interfacy.core.ancestor_options import InvocationState, bucket_for_command_path from interfacy.core.plugins import PluginManager from interfacy.declarations.settings import BackendName -from interfacy.exceptions import ConfigurationError +from interfacy.exceptions import ConfigurationError, ParseDiagnostic from interfacy.plugins import ( AbortRecovery, ArgumentDescriptor, @@ -21,9 +22,17 @@ from interfacy.schema.nodes import COMMAND_KEY, Command, ParserSchema, find_command -def usage_presentation(message: str, exit_code: int = 2) -> NativePresentation: +def usage_presentation( + message: str, exit_code: int = 2, *, diagnostic: ParseDiagnostic | None = None +) -> NativePresentation: """Return a usage-error presentation for ``message``.""" - return NativePresentation(kind="usage", message=message, exit_code=exit_code) + return NativePresentation( + kind="usage", + message=message, + diagnostic=diagnostic, + command_path=diagnostic.command_path if diagnostic is not None else (), + exit_code=exit_code, + ) def recover_parse_failure( @@ -66,7 +75,18 @@ def recover_parse_failure( if isinstance(action, AbortRecovery): message = action.message or failure.presentation.message - session.present_error(usage_presentation(message, action.exit_code)) + session.present_error( + replace( + failure.presentation, + message=message, + exit_code=action.exit_code, + diagnostic=ParseDiagnostic( + kind="usage", + command_path=failure.presentation.command_path, + message=message, + ), + ) + ) apply_recovery_action(schema, namespace, descriptor, action) @@ -160,12 +180,12 @@ def subcommands_at_path( ) -> dict[str, Command]: """Return the subcommands selectable at ``path``, or an empty mapping if it is unknown.""" commands = schema.commands - if not path: - if len(commands) == 1 and not schema.is_multi_command: - root = next(iter(commands.values())) - if root.command_type != "group" and root.subcommands: - return root.subcommands + if len(commands) == 1 and not schema.is_multi_command: + root = next(iter(commands.values())) + if root.command_type != "group": + commands = root.subcommands or {} + if not path: return commands for segment in path: diff --git a/interfacy/core/settings.py b/interfacy/core/settings.py index 9162999..a07750d 100644 --- a/interfacy/core/settings.py +++ b/interfacy/core/settings.py @@ -34,6 +34,7 @@ from interfacy.exceptions import ConfigurationError from interfacy.help.colors import InterfacyColors from interfacy.help.content import HelpRenderer +from interfacy.help.diagnostics import DiagnosticLayout from interfacy.help.layouts import HelpLayout from interfacy.naming import AbbreviationGenerator, FlagStrategy from interfacy.plugins import InterfacyPlugin @@ -57,6 +58,7 @@ class CoreSettings: help_layout: HelpLayout | None = field(default=None, metadata=RESETTABLE) help_colors: InterfacyColors | None = field(default=None, metadata=RESETTABLE) help_renderer: HelpRenderer | None = field(default=None, metadata=RESETTABLE) + diagnostic_layout: DiagnosticLayout | None = field(default=None, metadata=RESETTABLE) print_result: bool = field(default=False, metadata=REQUIRED) tab_completion: bool = field(default=False, metadata=REQUIRED) full_error_traceback: bool = field(default=False, metadata=REQUIRED) diff --git a/interfacy/exceptions.py b/interfacy/exceptions.py index b7d30e2..1128d90 100644 --- a/interfacy/exceptions.py +++ b/interfacy/exceptions.py @@ -1,3 +1,36 @@ +from dataclasses import dataclass +from typing import Literal + +DiagnosticKind = Literal[ + "unknown_command", + "unknown_option", + "missing_argument", + "missing_value", + "missing_subcommand", + "invalid_choice", + "invalid_type", + "unexpected_argument", + "usage", +] + + +@dataclass(frozen=True, kw_only=True) +class ParseDiagnostic: + """Backend-neutral failure facts; tokens contain names, never rejected parameter values.""" + + kind: DiagnosticKind + command_path: tuple[str, ...] = () + argument: str | None = None + token: str | None = None + choices: tuple[str, ...] = () + expected: str | None = None + message: str = "" + + def __post_init__(self) -> None: + object.__setattr__(self, "command_path", tuple(self.command_path)) + object.__setattr__(self, "choices", tuple(self.choices)) + + class InterfacyError(Exception): """Base exception for Interfacy errors.""" @@ -57,8 +90,19 @@ class ConfigurationError(InterfacyError): class UsageError(InterfacyError): """Raise when command-line input cannot be parsed.""" - def __init__(self, message: str, *, usage: str | None = None) -> None: + def __init__( + self, + message: str, + *, + usage: str | None = None, + diagnostic: ParseDiagnostic | None = None, + program: str | None = None, + exit_code: int = 2, + ) -> None: self.usage = usage + self.diagnostic = diagnostic + self.program = program + self.exit_code = exit_code super().__init__(message) @@ -73,11 +117,13 @@ def __init__(self, parameter: str, message: str) -> None: __all__ = [ "ConfigurationError", + "DiagnosticKind", "DuplicateCommandError", "DuplicatePluginError", "InterfacyError", "InterfacyExit", "InvalidCommandError", + "ParseDiagnostic", "PipeInputError", "ReservedFlagError", "UnsupportedParameterTypeError", diff --git a/interfacy/help/__init__.py b/interfacy/help/__init__.py index ed8eab2..d0526c9 100644 --- a/interfacy/help/__init__.py +++ b/interfacy/help/__init__.py @@ -8,6 +8,7 @@ HelpSectionKind, default_help_renderer, ) +from interfacy.help.diagnostics import DiagnosticContent, DiagnosticLayout, DiagnosticResult from interfacy.help.layouts import ( Aligned, AlignedTyped, @@ -25,6 +26,9 @@ "AlignedTyped", "ArgparseLayout", "ClapLayout", + "DiagnosticContent", + "DiagnosticLayout", + "DiagnosticResult", "HelpContent", "HelpContext", "HelpLayout", diff --git a/interfacy/help/diagnostics.py b/interfacy/help/diagnostics.py new file mode 100644 index 0000000..6f823ee --- /dev/null +++ b/interfacy/help/diagnostics.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import os +import sys +import textwrap +from dataclasses import dataclass, field +from difflib import get_close_matches +from typing import Literal + +from stdl.st import TextStyle, with_style + +from interfacy.common.terminal import strip_ansi +from interfacy.exceptions import DiagnosticKind, ParseDiagnostic + +DiagnosticSection = Literal["message", "suggestions", "choices", "hints", "usage", "help"] + + +@dataclass(frozen=True, kw_only=True) +class DiagnosticContent: + """Display content that plugins may transform without changing the parse failure.""" + + message: str + suggestions: tuple[str, ...] = () + choices: tuple[str, ...] = () + choices_heading: str = "Valid choices:" + hints: tuple[str, ...] = () + usage: str | None = None + help_command: str | None = None + help_text: str | None = None + + +@dataclass(frozen=True) +class DiagnosticResult: + """Final diagnostic text returned by a plugin renderer.""" + + text: str + + +def diagnostic_message(diagnostic: ParseDiagnostic) -> str: + if diagnostic.message: + return diagnostic.message + + argument = repr(diagnostic.argument) if diagnostic.argument else "argument" + expected = diagnostic.expected or "a valid value" + messages: dict[DiagnosticKind, str] = { + "unknown_command": f"unknown command {diagnostic.token!r}", + "unknown_option": f"unknown option {diagnostic.argument!r}", + "missing_argument": f"missing required argument {argument}", + "missing_value": f"{argument} requires a value", + "missing_subcommand": ( + f"a command is required; available commands: {', '.join(diagnostic.choices)}" + if diagnostic.choices + else "a command is required" + ), + "invalid_choice": f"invalid choice for {argument}", + "invalid_type": f"{argument} requires {expected}", + "unexpected_argument": "unexpected argument", + "usage": "invalid command-line input", + } + return messages[diagnostic.kind] + + +def build_diagnostic_content( + diagnostic: ParseDiagnostic, + *, + program: str, + help_flags: tuple[str, ...], + usage: str | None = None, +) -> DiagnosticContent: + suggestions: tuple[str, ...] = () + choices: tuple[str, ...] = () + heading = "Valid choices:" + if diagnostic.kind in ("unknown_command", "unknown_option"): + token = diagnostic.token if diagnostic.kind == "unknown_command" else diagnostic.argument + if token: + suggestions = tuple(get_close_matches(token, diagnostic.choices)) + + if diagnostic.kind == "unknown_command" and not suggestions: + choices = diagnostic.choices + heading = "Available commands:" + elif diagnostic.kind == "invalid_choice": + choices = diagnostic.choices + + show_usage = diagnostic.kind in ( + "missing_argument", + "missing_value", + "invalid_choice", + "invalid_type", + "unexpected_argument", + "usage", + ) + + return DiagnosticContent( + message=diagnostic_message(diagnostic), + suggestions=suggestions, + choices=choices, + choices_heading=heading, + usage=usage if show_usage else None, + help_command=f"{program} {help_flags[0]}" if help_flags else None, + help_text=usage if diagnostic.kind == "missing_subcommand" else None, + ) + + +@dataclass(kw_only=True) +class DiagnosticLayout: + """Arrange diagnostics independently of help; omitted sections are not displayed.""" + + error_prefix: str = "error:" + suggestions_heading: str = "Did you mean" + sections: tuple[DiagnosticSection, ...] = ( + "message", + "suggestions", + "choices", + "hints", + "usage", + "help", + ) + indent: str = "" + message_spacing: str = "\n\n" + section_spacing: str = "\n" + error_style: TextStyle = field(default_factory=lambda: TextStyle(color="red", style="bold")) + hint_style: TextStyle = field(default_factory=lambda: TextStyle(style="dim")) + + def render(self, content: DiagnosticContent, *, terminal_width: int) -> str: + if content.help_text: + return content.help_text.rstrip() + "\n" + + colored = ( + sys.stderr.isatty() + and "NO_COLOR" not in os.environ + and "NOCOLOR" not in os.environ + and os.environ.get("TERM") != "dumb" + ) + prefix = self.error_prefix + sections: dict[DiagnosticSection, str] = { + "message": f"{prefix} {content.message}", + "suggestions": ( + f"{self.suggestions_heading} {', '.join(repr(item) for item in content.suggestions)}?" + if content.suggestions + else "" + ), + "choices": ( + f"{content.choices_heading} {', '.join(content.choices)}" if content.choices else "" + ), + "hints": "\n".join(content.hints), + "usage": content.usage.rstrip() if content.usage else "", + "help": (f"Run '{content.help_command}' for usage." if content.help_command else ""), + } + rendered = "" + previous: DiagnosticSection | None = None + + for section in self.sections: + text = sections[section] + if not text: + continue + + wrapped = "\n".join( + textwrap.fill( + line, + width=terminal_width, + initial_indent=self.indent, + subsequent_indent=self.indent + " ", + break_long_words=False, + break_on_hyphens=False, + ) + for line in text.splitlines() + ) + if colored and section == "message": + wrapped = wrapped.replace(prefix, with_style(prefix, self.error_style), 1) + + if colored and section == "help": + wrapped = with_style(wrapped, self.hint_style) + + if rendered: + rendered += self.message_spacing if previous == "message" else self.section_spacing + + rendered += wrapped + previous = section + + return rendered.rstrip() + "\n" + + +def normalize_diagnostic_text(text: str) -> str: + if not isinstance(text, str): + raise TypeError("diagnostic renderers must return str") + + if ( + not sys.stderr.isatty() + or "NO_COLOR" in os.environ + or "NOCOLOR" in os.environ + or os.environ.get("TERM") == "dumb" + ): + text = strip_ansi(text) + + return text.rstrip("\r\n") + "\n" + + +__all__ = [ + "DiagnosticContent", + "DiagnosticLayout", + "DiagnosticResult", + "DiagnosticSection", + "build_diagnostic_content", + "diagnostic_message", + "normalize_diagnostic_text", +] diff --git a/interfacy/help/renderer.py b/interfacy/help/renderer.py index 093fee8..62d43a8 100644 --- a/interfacy/help/renderer.py +++ b/interfacy/help/renderer.py @@ -130,6 +130,25 @@ def terminal_width(self) -> int: def terminal_width(self, value: int | None) -> None: self._configured_terminal_width = value + def render_usage( + self, schema: ParserSchema, prog: str, *, command: Command | None = None + ) -> str: + """Render only usage from the same schema and layout used for help.""" + with self._render_scope(): + if command is None and len(schema.commands) == 1: + command = next(iter(schema.commands.values())) + + if command is None: + return self._build_parser_usage(schema, prog) + + return self._build_usage( + command, + prog, + parser_executable_flags=schema.executable_flags + if command.parent_path == () + else None, + ) + def render_parser_help(self, schema: ParserSchema, prog: str) -> str: """ Render help text for a parser schema and program name. @@ -354,9 +373,8 @@ def _build_epilog_block( parts = [p for p in (command.epilog, parser_epilog) if p] return "\n\n".join(parts) or None - def _render_multi_command_help(self, schema: ParserSchema, prog: str) -> str: + def _build_parser_usage(self, schema: ParserSchema, prog: str) -> str: layout = self.layout - sections: list[HelpSection] = [] usage_prog = self._style_usage_text(self._normalize_prog(prog)) usage_prefix = self._get_usage_prefix() usage_suffix = self._usage_token_for_commands( @@ -377,9 +395,14 @@ def _render_multi_command_help(self, schema: ParserSchema, prog: str) -> str: else: usage = f"{usage_prefix}{usage_text}" + return usage + + def _render_multi_command_help(self, schema: ParserSchema, prog: str) -> str: + layout = self.layout + sections: list[HelpSection] = [] self._append_usage_and_description( sections=sections, - usage=usage, + usage=self._build_parser_usage(schema, prog), description=schema.description, ) diff --git a/interfacy/plugins/__init__.py b/interfacy/plugins/__init__.py index ad8f25c..cf85367 100644 --- a/interfacy/plugins/__init__.py +++ b/interfacy/plugins/__init__.py @@ -4,6 +4,7 @@ BackendPluginContext, BeforeParseContext, ConfigureContext, + DiagnosticHookContext, ExecuteContext, HelpHookContext, ParseFailureContext, @@ -28,6 +29,7 @@ "BackendPluginContext", "BeforeParseContext", "ConfigureContext", + "DiagnosticHookContext", "ExecuteContext", "HelpHookContext", "InterfacyPlugin", diff --git a/interfacy/plugins/base.py b/interfacy/plugins/base.py index dc5c980..061d0f3 100644 --- a/interfacy/plugins/base.py +++ b/interfacy/plugins/base.py @@ -5,11 +5,13 @@ from interfacy.declarations.settings import BackendName from interfacy.help.content import HelpContent, HelpResult +from interfacy.help.diagnostics import DiagnosticContent, DiagnosticResult from interfacy.plugins.contexts import ( AfterParseContext, BackendPluginContext, BeforeParseContext, ConfigureContext, + DiagnosticHookContext, ExecuteContext, HelpHookContext, ParseFailureContext, @@ -78,6 +80,26 @@ def render_help( return None + def transform_diagnostic( + self, + context: DiagnosticHookContext, + content: DiagnosticContent, + ) -> DiagnosticContent: + """Transform display content after recovery fails, without changing failure facts.""" + del context + + return content + + def render_diagnostic( + self, + context: DiagnosticHookContext, + content: DiagnosticContent, + ) -> DiagnosticResult | None: + """Return diagnostic text, or defer to the next renderer and native layout.""" + del context, content + + return None + def wrap_execute( self, context: ExecuteContext, diff --git a/interfacy/plugins/contexts.py b/interfacy/plugins/contexts.py index 8698f0a..b4b8fb4 100644 --- a/interfacy/plugins/contexts.py +++ b/interfacy/plugins/contexts.py @@ -5,6 +5,7 @@ from typing import Any from interfacy.declarations.settings import BackendName +from interfacy.exceptions import ParseDiagnostic from interfacy.plugins.descriptors import SchemaDescriptor from interfacy.plugins.freeze import freeze @@ -80,6 +81,21 @@ def __post_init__(self) -> None: self.command_path = tuple(self.command_path) +@dataclass(slots=True) +class DiagnosticHookContext: + """Failure facts and command context; display transforms do not alter these facts.""" + + backend: BackendName + metadata: Mapping[str, Any] + program: str + terminal_width: int + diagnostic: ParseDiagnostic + schema: SchemaDescriptor + + def __post_init__(self) -> None: + self.metadata = freeze(self.metadata) + + @dataclass(slots=True) class ExecuteContext: """Invocation snapshot visible to execution wrappers.""" @@ -126,6 +142,7 @@ class BackendPluginContext: "BackendPluginContext", "BeforeParseContext", "ConfigureContext", + "DiagnosticHookContext", "ExecuteContext", "HelpHookContext", "ParseFailureContext", diff --git a/interfacy/runtime/invocation.py b/interfacy/runtime/invocation.py index 3cd974c..8c92f5a 100644 --- a/interfacy/runtime/invocation.py +++ b/interfacy/runtime/invocation.py @@ -69,7 +69,7 @@ def resolve_args(args: Sequence[str] | None) -> tuple[str, ...]: class InvocationError(Exception): - def __init__(self, code: ExitCode, error: Exception) -> None: + def __init__(self, code: int, error: Exception) -> None: self.code = code self.error = error super().__init__(type(error).__name__) @@ -85,9 +85,9 @@ def _execution_error_code(error: Exception) -> ExitCode: return ExitCode.COMMAND_FAILED -def _parse_error_code(error: Exception) -> ExitCode: +def _parse_error_code(error: Exception) -> int: if isinstance(error, UsageError): - return ExitCode.USAGE + return error.exit_code if isinstance(error, _CONFIGURATION_ERRORS): return ExitCode.CONFIGURATION return ExitCode.INTERNAL @@ -134,20 +134,28 @@ def run(self, parse: ParseStep, execute: ExecuteStep) -> NoReturn: raise except InvocationError as e: failure = e.error + exit_code = e.code if isinstance(failure, UsageError): - if failure.usage: - error(failure.usage.rstrip()) - - self._policy.log_error(str(failure)) + try: + rendered = self._render_diagnostic(failure) + except InvocationError as rendering_error: + self._policy.log_exception(rendering_error.error) + exit_code = rendering_error.code + else: + error(rendered.rstrip()) else: self._policy.log_exception(failure) - - exit_code = e.code else: self._policy.display(result) raise SystemExit(exit_code) + def _render_diagnostic(self, failure: UsageError) -> str: + try: + return self._policy.format_diagnostic(failure) + except Exception as e: + raise InvocationError(_parse_error_code(e), e) from e + def _invoke(self, parse: ParseStep, execute: ExecuteStep) -> Any: try: invocation = self._parse(parse) diff --git a/interfacy/runtime/policy.py b/interfacy/runtime/policy.py index 899bc53..6369bbc 100644 --- a/interfacy/runtime/policy.py +++ b/interfacy/runtime/policy.py @@ -1,10 +1,19 @@ from __future__ import annotations +import sys from collections.abc import Callable from dataclasses import dataclass +from pathlib import Path from typing import Any from interfacy.common.console import log, log_error, log_exception, log_interrupt +from interfacy.common.terminal import get_terminal_width +from interfacy.exceptions import ParseDiagnostic, UsageError +from interfacy.help.diagnostics import ( + DiagnosticLayout, + build_diagnostic_content, + normalize_diagnostic_text, +) @dataclass @@ -15,6 +24,7 @@ class RuntimePolicy: on_interrupt: Callable[[KeyboardInterrupt], None] | None silent_interrupt: bool logger_message_tag: str = "interfacy" + diagnostic_renderer: Callable[[UsageError], str] | None = None def display(self, value: Any) -> None: if self.display_result: @@ -26,6 +36,22 @@ def handle_interrupt(self, e: BaseException) -> None: self.log_interrupt() + def format_diagnostic(self, failure: UsageError) -> str: + if self.diagnostic_renderer is not None: + return normalize_diagnostic_text(self.diagnostic_renderer(failure)) + + diagnostic = failure.diagnostic or ParseDiagnostic(kind="usage", message=str(failure)) + content = build_diagnostic_content( + diagnostic, + program=failure.program or Path(sys.argv[0]).name, + help_flags=(), + usage=failure.usage, + ) + + return normalize_diagnostic_text( + DiagnosticLayout().render(content, terminal_width=get_terminal_width()) + ) + def log(self, message: str) -> None: log(self.logger_message_tag, message) diff --git a/tests/conftest.py b/tests/conftest.py index 5edb1ec..b736f23 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,12 @@ def parser(request: pytest.FixtureRequest) -> Interfacy: return request.getfixturevalue(fixture_name) +@pytest.fixture +def script_invocation(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "argv", ["tool"]) + monkeypatch.setattr("__main__.__spec__", None) + + @pytest.fixture def argparse_req_pos() -> Interfacy: parser = Interfacy( diff --git a/tests/contracts/backends/test_async_execution.py b/tests/contracts/backends/test_async_execution.py index ec63ead..d85f74b 100644 --- a/tests/contracts/backends/test_async_execution.py +++ b/tests/contracts/backends/test_async_execution.py @@ -5,7 +5,7 @@ import pytest -from interfacy import CommandGroup, Interfacy +from interfacy import CommandGroup, Interfacy, UsageError class AsyncMath: @@ -95,6 +95,19 @@ def test_async_group_chain_leaf_execution(self, parser: Interfacy) -> None: _assert_no_unawaited_runtime_warning(caught) +@pytest.mark.parametrize("parser", ["argparse_req_pos", "click_req_pos"], indirect=True) +def test_async_invoke_exposes_structured_failure_without_output(parser, capsys): + async def command(count: int) -> None: + pytest.fail("Rejected input must not execute") + + with pytest.raises(UsageError) as failure: + asyncio.run(parser.invoke_async(command, args=["private-input"])) + + assert failure.value.diagnostic.kind == "invalid_type" + assert "private-input" not in str(failure.value) + assert capsys.readouterr() == ("", "") + + class TestSyncExecution: @pytest.mark.parametrize("parser", ["argparse_req_pos", "click_req_pos"], indirect=True) def test_sync_function_can_call_asyncio_run(self, parser: Interfacy) -> None: diff --git a/tests/contracts/backends/test_errors.py b/tests/contracts/backends/test_errors.py index ab0cfed..90ae59a 100644 --- a/tests/contracts/backends/test_errors.py +++ b/tests/contracts/backends/test_errors.py @@ -1,9 +1,16 @@ -from typing import Any +from typing import Any, Literal import pytest from pydantic import BaseModel, Field -from interfacy import ConfigurationError, ExitCode, Interfacy, InterfacyError, UsageError +from interfacy import ( + ConfigurationError, + DiagnosticLayout, + ExitCode, + Interfacy, + InterfacyError, + UsageError, +) from interfacy.naming import DefaultFlagStrategy from interfacy.plugins import AfterParseContext, InterfacyPlugin @@ -237,3 +244,170 @@ def command() -> None: assert e.value.code == ExitCode.COMMAND_FAILED assert e.value.__cause__ is None assert e.value.__context__ is None + + +@pytest.mark.parametrize( + ("args", "kind", "argument"), + [ + ([], "missing_argument", "--workers"), + (["--workers"], "missing_value", "--workers"), + (["--workers", "private-input"], "invalid_type", "--workers"), + (["--workers", "2", "--mode", "private-input"], "invalid_choice", "--mode"), + (["--workers", "2", "--unknown"], "unknown_option", "--unknown"), + (["--workers", "2", "private-input"], "unexpected_argument", None), + ], +) +def test_parse_diagnostic_classifies_input_without_exposing_values( + parser: Interfacy, + args: list[str], + kind: str, + argument: str | None, + capsys: pytest.CaptureFixture[str], +) -> None: + parser.apply_setup(flag_strategy=DefaultFlagStrategy(style="keyword_only")) + + def command(*, workers: int, mode: Literal["fast", "slow"] = "fast") -> None: + pytest.fail("Rejected input must not execute the command") + + with pytest.raises(UsageError) as failure: + parser.invoke(command, args=args) + + diagnostic = failure.value.diagnostic + assert diagnostic is not None + assert diagnostic.kind == kind + assert diagnostic.argument == argument + assert "private-input" not in str(failure.value) + assert capsys.readouterr() == ("", "") + + +def test_positional_diagnostic_uses_the_displayed_metavar( + parser: Interfacy, +) -> None: + def command(count: int) -> None: + pytest.fail("Rejected input must not execute") + + with pytest.raises(UsageError) as failure: + parser.invoke(command, args=["private-input"]) + + assert failure.value.diagnostic.argument == "COUNT" + assert failure.value.diagnostic.kind == "invalid_type" + assert "private-input" not in str(failure.value) + + +def test_unknown_command_is_concise_and_suggests_without_executing( + parser: Interfacy, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + script_invocation: None, +) -> None: + monkeypatch.setenv("NO_COLOR", "1") + + def command() -> None: + pytest.fail("A spelling suggestion must not execute the command") + + parser.add_command(command, name="publish") + parser.add_command(command, name="inspect") + with pytest.raises(SystemExit) as failure: + parser.run(args=["publsih"]) + + captured = capsys.readouterr() + assert failure.value.code == 2 + assert captured.out == "" + assert captured.err == ( + "error: unknown command 'publsih'\n\n" + "Did you mean 'publish'?\n" + "Run 'tool --help' for usage.\n" + ) + + +def test_distant_unknown_command_lists_available_commands( + parser: Interfacy, + capsys: pytest.CaptureFixture[str], + script_invocation: None, +) -> None: + parser.add_command(lambda: None, name="publish") + parser.add_command(lambda: None, name="inspect") + + with pytest.raises(SystemExit): + parser.run(args=["xyz"]) + + captured = capsys.readouterr() + assert "Available commands: publish, inspect" in captured.err + assert "Did you mean" not in captured.err + assert "usage:" not in captured.err.lower() + + +@pytest.mark.parametrize("help_flags", [("--assist",), ("-?", "--assist")]) +def test_error_hint_uses_only_configured_help_flags( + parser: Interfacy, + help_flags: tuple[str, ...], + capsys: pytest.CaptureFixture[str], + script_invocation: None, +) -> None: + parser.apply_setup(help_flags=help_flags) + parser.add_command(lambda: None, name="publish") + parser.add_command(lambda: None, name="inspect") + + with pytest.raises(SystemExit): + parser.run(args=["xyz"]) + + output = capsys.readouterr().err + assert "--help" not in output + assert f"Run 'tool {help_flags[0]}'" in output + + +def test_unknown_option_does_not_echo_attached_value( + parser: Interfacy, + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit): + parser.run(lambda: None, args=["--unknown=private-input"]) + + captured = capsys.readouterr() + assert captured.out == "" + assert "unknown option '--unknown'" in captured.err + assert "private-input" not in captured.err + + +def test_diagnostic_layout_changes_presentation_not_failure( + parser: Interfacy, + capsys: pytest.CaptureFixture[str], + script_invocation: None, +) -> None: + parser.apply_setup( + diagnostic_layout=DiagnosticLayout( + error_prefix="invalid:", sections=("message",), indent=" " + ) + ) + parser.add_command(lambda: None, name="publish") + parser.add_command(lambda: None, name="inspect") + + with pytest.raises(SystemExit) as failure: + parser.run(args=["xyz"]) + + assert failure.value.code == 2 + assert capsys.readouterr() == ("", " invalid: unknown command 'xyz'\n") + + +@pytest.mark.parametrize("environment", [{"NO_COLOR": "1"}, {"TERM": "dumb"}, {}]) +def test_redirected_diagnostics_are_plain_and_wrap_at_terminal_width( + parser: Interfacy, + environment: dict[str, str], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("COLUMNS", "38") + for name, value in environment.items(): + monkeypatch.setenv(name, value) + + parser.add_command(lambda: None, name="publish") + parser.add_command(lambda: None, name="inspect") + parser.add_command(lambda: None, name="synchronize") + + with pytest.raises(SystemExit): + parser.run(args=["xyz"]) + + captured = capsys.readouterr() + assert captured.out == "" + assert "\x1b[" not in captured.err + assert max(map(len, captured.err.splitlines())) <= 38 diff --git a/tests/contracts/backends/test_help_errors.py b/tests/contracts/backends/test_help_errors.py index ff5f38a..c895e51 100644 --- a/tests/contracts/backends/test_help_errors.py +++ b/tests/contracts/backends/test_help_errors.py @@ -1,7 +1,10 @@ import pytest +from interfacy import Interfacy, Param from interfacy.declarations.groups import CommandGroup from interfacy.exceptions import UsageError +from interfacy.help import StandardLayout +from interfacy.naming import DefaultFlagStrategy @pytest.mark.parametrize("parser", ["argparse_req_pos", "click_req_pos"], indirect=True) @@ -30,3 +33,135 @@ def test_missing_nested_subcommand_reports_available_command(parser): combined = f"{exc_info.value}\n{exc_info.value.usage or ''}" assert "attach" in combined + + +@pytest.mark.parametrize("parser", ["argparse_req_pos", "click_req_pos"], indirect=True) +def test_nested_unknown_command_uses_siblings_and_parent_help(parser, capsys, script_invocation): + workspace = CommandGroup("workspace") + module = CommandGroup("module") + module.add_command(lambda: None, name="attach") + module.add_command(lambda: None, name="detach") + workspace.add_group(module) + workspace.add_command(lambda: None, name="inspect") + parser.add_command(workspace) + + with pytest.raises(SystemExit) as failure: + parser.run(args=["workspace", "module", "xyz"]) + + captured = capsys.readouterr() + assert failure.value.code == 2 + assert captured.out == "" + assert "Available commands: attach, detach" in captured.err + assert "inspect" not in captured.err + assert "Run 'tool workspace module --help'" in captured.err + + +@pytest.mark.parametrize("parser", ["argparse_req_pos", "click_req_pos"], indirect=True) +def test_missing_subcommand_prints_parent_help_without_parser_error(parser, capsys): + workspace = CommandGroup("workspace") + + def attach() -> None: + """Attach a module.""" + + def detach() -> None: + """Detach a module.""" + + workspace.add_command(attach) + workspace.add_command(detach) + parser.add_command(workspace) + + with pytest.raises(SystemExit) as failure: + parser.run(args=["workspace"]) + + captured = capsys.readouterr() + assert failure.value.code == 2 + assert captured.out == "" + assert "Attach a module" in captured.err + assert "Detach a module" in captured.err + assert "the following arguments are required" not in captured.err + assert "error:" not in captured.err + + +@pytest.mark.parametrize("parser", ["argparse_req_pos", "click_req_pos"], indirect=True) +def test_nested_missing_argument_reports_leaf_usage_and_diagnostic_path(parser, capsys): + parser.apply_setup(flag_strategy=DefaultFlagStrategy(style="keyword_only")) + + def upload(*, workers: int) -> None: + pytest.fail("Missing input must not execute") + + workspace = CommandGroup("workspace") + workspace.add_command(upload) + parser.add_command(workspace) + + with pytest.raises(UsageError) as failure: + parser.invoke(args=["workspace", "upload"]) + + assert failure.value.diagnostic.command_path == ("workspace", "upload") + assert failure.value.diagnostic.argument == "--workers" + assert "upload" in failure.value.usage + assert capsys.readouterr() == ("", "") + + +@pytest.mark.parametrize("backend", ["argparse", "click"]) +def test_unknown_command_lists_canonical_names_once_with_aliases(backend, capsys): + parser = Interfacy(backend=backend) + parser.add_command(lambda: None, name="publish", aliases=["send"]) + parser.add_command(lambda: None, name="inspect") + + with pytest.raises(SystemExit): + parser.run(args=["xyz"]) + + output = capsys.readouterr().err + assert "Available commands: publish, inspect" in output + assert "send" not in output + + +@pytest.mark.parametrize("backend", ["argparse", "click"]) +def test_unknown_option_can_suggest_an_ancestor_option(backend, capsys): + class Service: + def __init__(self, verbose: bool = False) -> None: + self.verbose = verbose + + def status(self) -> None: + pytest.fail("Suggestions must not execute") + + parser = Interfacy(backend=backend) + with pytest.raises(SystemExit): + parser.run(Service, args=["status", "--verbsoe"]) + + assert "Did you mean '--verbose'?" in capsys.readouterr().err + + +@pytest.mark.parametrize("backend", ["argparse", "click"]) +def test_diagnostic_usage_reuses_the_configured_help_layout(backend, capsys, script_invocation): + def command(*, workers: int = 1) -> None: + pytest.fail("Rejected input must not execute") + + parser = Interfacy(backend=backend, help_layout=StandardLayout(usage_prefix="Usage: ")) + parser.add_command(command, params={"workers": Param(short=False)}) + with pytest.raises(SystemExit): + parser.run(args=["--workers", "bad"]) + + output = capsys.readouterr().err + assert "Usage: tool [--help] [--workers]" in output + + +@pytest.mark.parametrize("backend", ["argparse", "click"]) +def test_missing_subcommand_in_implicit_class_shows_its_own_help( + backend, capsys, script_invocation +): + class Service: + def publish(self, count: int) -> None: + """Publish an artifact.""" + + def inspect(self) -> None: + """Inspect an artifact.""" + + parser = Interfacy(backend=backend) + parser.add_command(Service) + with pytest.raises(SystemExit): + parser.run(args=["publish"]) + + output = capsys.readouterr().err + assert "COUNT" in output + assert "Run 'tool publish --help'" in output diff --git a/tests/contracts/backends/test_parsing.py b/tests/contracts/backends/test_parsing.py index 9f43fd2..56a986f 100644 --- a/tests/contracts/backends/test_parsing.py +++ b/tests/contracts/backends/test_parsing.py @@ -238,9 +238,13 @@ def run(self) -> list[int] | None: parser.add_command(TagsTool) - with pytest.raises(UsageError): + with pytest.raises(UsageError) as failure: parser.parse_args(["run", "--tags", "bad"]) + assert failure.value.diagnostic.kind == "invalid_type" + assert failure.value.diagnostic.argument == "--tags" + assert "bad" not in str(failure.value) + @pytest.mark.parametrize("parser", ["click_kw_only"], indirect=True) def test_typed_late_initializer_list_option_parse_error_uses_click( self, @@ -258,9 +262,13 @@ def run(self) -> list[int] | None: parser.add_command(TagsTool) - with pytest.raises(UsageError): + with pytest.raises(UsageError) as failure: parser.parse_args(["run", "--tags", "bad"]) + assert failure.value.diagnostic.kind == "invalid_type" + assert failure.value.diagnostic.argument == "--tags" + assert "bad" not in str(failure.value) + @pytest.mark.parametrize("parser", ["argparse_req_pos", "click_req_pos"], indirect=True) def test_from_instance(self, parser: Interfacy): """Verify parsing of commands derived from instance methods.""" diff --git a/tests/contracts/backends/test_plugins.py b/tests/contracts/backends/test_plugins.py index 1feddc9..541b54e 100644 --- a/tests/contracts/backends/test_plugins.py +++ b/tests/contracts/backends/test_plugins.py @@ -1,16 +1,19 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import replace from typing import Any import pytest from interfacy import Interfacy from interfacy.exceptions import ConfigurationError, DuplicatePluginError, UsageError -from interfacy.help import HelpContent, HelpSection +from interfacy.help import DiagnosticContent, DiagnosticResult, HelpContent, HelpSection +from interfacy.naming import DefaultFlagStrategy from interfacy.plugins import ( AfterParseContext, BeforeParseContext, + DiagnosticHookContext, HelpHookContext, InterfacyPlugin, ParseFailureKind, @@ -404,3 +407,108 @@ def test_wrap_execute_plugins_wrap_execution_in_registration_order(parser) -> No assert result == "Ada|inner|outer" assert order == ["outer:before", "inner:before", "inner:after", "outer:after"] + + +class DiagnosticPlugin(InterfacyPlugin): + def __init__(self, name: str, *, render: bool = False) -> None: + self.name = name + self.render = render + + def transform_diagnostic( + self, context: DiagnosticHookContext, content: DiagnosticContent + ) -> DiagnosticContent: + assert context.diagnostic.kind == "missing_argument" + return replace(content, hints=(*content.hints, self.plugin_name)) + + def render_diagnostic( + self, context: DiagnosticHookContext, content: DiagnosticContent + ) -> DiagnosticResult | None: + if not self.render: + return None + + return DiagnosticResult(f"{self.plugin_name}: {' then '.join(content.hints)}") + + +@pytest.mark.parametrize("backend", ["argparse", "click"]) +def test_diagnostic_transforms_compose_and_first_renderer_wins(backend, capsys): + parser = Interfacy( + backend=backend, + plugins=[ + DiagnosticPlugin("first"), + DiagnosticPlugin("second", render=True), + DiagnosticPlugin("third", render=True), + ], + ) + + with pytest.raises(SystemExit) as failure: + parser.run(_keyword_only_name, args=[]) + + assert failure.value.code == 2 + assert capsys.readouterr() == ("", "second: first then second then third\n") + + +@pytest.mark.parametrize("backend", ["argparse", "click"]) +def test_diagnostic_transform_uses_native_layout_when_renderers_defer(backend, capsys): + parser = Interfacy( + backend=backend, + plugins=[DiagnosticPlugin("custom guidance")], + flag_strategy=DefaultFlagStrategy(style="keyword_only"), + ) + + with pytest.raises(SystemExit): + parser.run(_keyword_only_name, args=[]) + + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err.startswith("error: missing required argument '--name'") + assert "custom guidance" in captured.err + + +@pytest.mark.parametrize("backend", ["argparse", "click"]) +def test_invoke_does_not_call_diagnostic_display_hooks(backend, capsys): + class FailOnDisplay(InterfacyPlugin): + def transform_diagnostic(self, context, content): + pytest.fail("Embedded invocation must not construct display content") + + def render_diagnostic(self, context, content): + pytest.fail("Embedded invocation must not render") + + parser = Interfacy(backend=backend, plugins=[FailOnDisplay()]) + + with pytest.raises(UsageError) as failure: + parser.invoke(_keyword_only_name, args=[]) + + assert failure.value.diagnostic.kind == "missing_argument" + assert capsys.readouterr() == ("", "") + + +@pytest.mark.parametrize("backend", ["argparse", "click"]) +def test_successful_recovery_does_not_render_a_diagnostic(backend, capsys): + class FailOnDisplay(InterfacyPlugin): + def transform_diagnostic(self, context, content): + pytest.fail("Successful recovery must not display an error") + + def render_help(self, context, content): + pytest.fail("Successful recovery must not render help") + + parser = Interfacy(backend=backend, plugins=[FillMissingValuePlugin(), FailOnDisplay()]) + + with pytest.raises(SystemExit) as failure: + parser.run(_keyword_only_name, args=[]) + + assert failure.value.code == 0 + assert capsys.readouterr() == ("", "") + + +@pytest.mark.parametrize("backend", ["argparse", "click"]) +def test_embedded_missing_command_does_not_render_help(backend, capsys): + class FailOnHelp(InterfacyPlugin): + def render_help(self, context, content): + pytest.fail("Embedded failure must expose facts without rendering help") + + parser = Interfacy(backend=backend, plugins=[FailOnHelp()]) + with pytest.raises(UsageError) as failure: + parser.invoke(Worker, args=[]) + + assert failure.value.diagnostic.kind == "missing_subcommand" + assert capsys.readouterr() == ("", "") diff --git a/tests/integration/argparse/test_manual_parser.py b/tests/integration/argparse/test_manual_parser.py index b954569..0643691 100644 --- a/tests/integration/argparse/test_manual_parser.py +++ b/tests/integration/argparse/test_manual_parser.py @@ -8,7 +8,7 @@ from interfacy.backends.argparse import ArgumentParser from interfacy.common.terminal import strip_ansi from interfacy.exceptions import InterfacyExit, UsageError -from interfacy.help import Modern +from interfacy.help import DiagnosticLayout, Modern def build_parser(*, sys_exit_enabled: bool = True) -> ArgumentParser: @@ -49,12 +49,12 @@ def test_manual_parser_help_prints_help_and_exits_zero( ( ["qa"], "usage: deploy [--help] ENVIRONMENT {status} ...", - "deploy: error: argument ENVIRONMENT: invalid choice: 'qa'", + "error: invalid choice for 'ENVIRONMENT'", ), ( ["staging", "status", "--count", "x"], "usage: deploy ENVIRONMENT status [--help] [--count COUNT]", - "deploy ENVIRONMENT status: error: argument --count: invalid int value: 'x'", + "error: '--count' requires an integer", ), ], ) @@ -70,11 +70,11 @@ def test_manual_parser_usage_error_reports_to_stderr_and_exits_two( parser.parse_args(args) captured = capsys.readouterr() - usage_line, error_line = captured.err.splitlines() assert excinfo.value.code == 2 assert captured.out == "" - assert usage_line == usage - assert error_line.startswith(error_prefix) + assert usage in captured.err + assert captured.err.splitlines()[0] == error_prefix + assert "[interfacy]" not in captured.err @pytest.mark.parametrize("args", [["--help"], ["staging", "status", "--help"]]) @@ -99,11 +99,32 @@ def test_manual_parser_without_sys_exit_raises_usage_error_from_subcommand( with pytest.raises(UsageError) as excinfo: parser.parse_args(["staging", "status", "--count", "x"]) - assert str(excinfo.value) == "argument --count: invalid int value: 'x'" + assert str(excinfo.value) == "'--count' requires an integer" + assert excinfo.value.diagnostic.kind == "invalid_type" assert excinfo.value.usage == "usage: deploy ENVIRONMENT status [--help] [--count COUNT]\n" assert capsys.readouterr().err == "" +def test_manual_parser_inherits_diagnostic_layout_without_disabled_help_hint(capsys): + parser = ArgumentParser( + prog="tool", + add_help=False, + diagnostic_layout=DiagnosticLayout(error_prefix="invalid:"), + ) + child = parser.add_subparsers(dest="command").add_parser("publish", add_help=False) + child.add_argument("--count", type=int) + + with pytest.raises(SystemExit) as failure: + parser.parse_args(["publish", "--count", "private-input"]) + + captured = capsys.readouterr() + assert failure.value.code == 2 + assert captured.out == "" + assert captured.err.startswith("invalid: '--count' requires an integer") + assert "Run '" not in captured.err + assert "private-input" not in captured.err + + def test_nested_manual_parser_uses_leaf_metavar_for_append_action() -> None: parser = ArgumentParser(prog="manual") subparsers = parser.add_subparsers(dest="command", required=True) @@ -140,7 +161,7 @@ def test_argument_parser_disables_argparse_usage_colors_when_python_colors_force with pytest.raises(UsageError) as excinfo: parser.parse_args(["status"]) - assert "invalid choice: 'status'" in str(excinfo.value) + assert str(excinfo.value) == "unknown command 'status'" assert excinfo.value.usage == strip_ansi(excinfo.value.usage or "") diff --git a/tests/integration/argparse/types/test_literal_aliases.py b/tests/integration/argparse/types/test_literal_aliases.py index 67397d4..ed78b30 100644 --- a/tests/integration/argparse/types/test_literal_aliases.py +++ b/tests/integration/argparse/types/test_literal_aliases.py @@ -141,8 +141,8 @@ def choose(val: Literal["a", "b"] | int) -> Literal["a", "b"] | int: assert cli.invoke(args=["42"]) == 42 -def test_invalid_literal_error_names_value_without_stray_quote() -> None: - """Verify error messages for invalid choices do not contain stray quote marks.""" +def test_invalid_literal_error_identifies_argument_without_rejected_value() -> None: + """Invalid choices identify the CLI argument without echoing rejected input.""" def pick(choice: Literal["apple", "banana"] = "apple") -> str: return choice @@ -154,8 +154,8 @@ def pick(choice: Literal["apple", "banana"] = "apple") -> str: cli.invoke(args=["--choice", "orange"]) message = str(exc_info.value) - assert "'orange'" in message - assert '"' not in message + assert message == "invalid choice for '--choice'" + assert exc_info.value.diagnostic.choices == ("apple", "banana") def _make_literal_alias() -> object: diff --git a/tests/integration/argparse/types/test_primitives.py b/tests/integration/argparse/types/test_primitives.py index 1f269fd..76a8b0f 100644 --- a/tests/integration/argparse/types/test_primitives.py +++ b/tests/integration/argparse/types/test_primitives.py @@ -14,7 +14,7 @@ class TestPrimitives: @pytest.mark.parametrize("parser", ["argparse_req_pos"], indirect=True) def test_bad_int_error_uses_user_facing_type_name(self, parser: Interfacy): - """Invalid typed values should show argparse-style type names.""" + """Invalid typed values identify the option and expected input without echoing it.""" def greet(name: str, times: int = 1) -> str: return name * times @@ -24,8 +24,9 @@ def greet(name: str, times: int = 1) -> str: with pytest.raises(UsageError) as excinfo: parser.parse_args(["Ada", "--times", "bad"]) - assert "argument -t/--times: invalid int value: 'bad'" in str(excinfo.value) - assert "parser[int]" not in str(excinfo.value) + assert str(excinfo.value) == "'--times' requires an integer" + assert excinfo.value.diagnostic.argument == "--times" + assert "bad" not in str(excinfo.value) @pytest.mark.parametrize("parser", ["argparse_req_pos", "argparse_kw_only"], indirect=True) def test_str_required(self, parser: Interfacy): diff --git a/tests/integration/cli/test_main.py b/tests/integration/cli/test_main.py index 7c82e95..f90a2d6 100644 --- a/tests/integration/cli/test_main.py +++ b/tests/integration/cli/test_main.py @@ -133,7 +133,7 @@ def test_main_file_target_missing_symbol_mentions_file_path( assert main([f"{module_path}:missing"]) == ExitCode.USAGE captured = capsys.readouterr() - assert f"module '{module_path}'" in captured.err + assert f"module '{module_path}'" in " ".join(captured.err.split()) assert "interfacy_entry_" not in captured.err diff --git a/tests/unit/core/test_facade.py b/tests/unit/core/test_facade.py index f4eac72..a588ee0 100644 --- a/tests/unit/core/test_facade.py +++ b/tests/unit/core/test_facade.py @@ -68,7 +68,7 @@ def test_public_params_type_hints_resolve() -> None: @pytest.mark.parametrize("backend", ["argparse", "click"]) -def test_configured_help_renderer_is_backend_neutral(backend: str) -> None: +def test_configured_help_renderer_is_backend_neutral(backend: str, script_invocation: None) -> None: if backend == "click": pytest.importorskip("click") @@ -80,9 +80,13 @@ def command() -> None: help_renderer=lambda context, content: f"{context.prog}:{len(content.sections)}", ) parser.add_command(command) - help_text = render_help(parser) + native = parser.build_parser() + if isinstance(native, click.Command): + help_text = native.get_help(click.Context(native, info_name="tool")) + else: + help_text = native.format_help() - assert help_text.startswith("main:") + assert help_text.startswith("tool:") assert help_text.endswith("\n") assert not help_text.endswith("\n\n") From a5ee49c8cd612293c218d3f8abc21f1b99f77d04 Mon Sep 17 00:00:00 2001 From: zigai Date: Fri, 16 Oct 2026 01:55:24 +0200 Subject: [PATCH 2/6] fix(ci): keep native diagnostic checks portable --- interfacy/backends/argparse/parser.py | 36 +++++++------- interfacy/backends/click/adapter.py | 62 ++++++++++++------------- interfacy/backends/click/parser.py | 4 +- tests/contracts/backends/test_errors.py | 7 ++- 4 files changed, 54 insertions(+), 55 deletions(-) diff --git a/interfacy/backends/argparse/parser.py b/interfacy/backends/argparse/parser.py index a13f5ec..eeb2bf1 100644 --- a/interfacy/backends/argparse/parser.py +++ b/interfacy/backends/argparse/parser.py @@ -52,7 +52,7 @@ @dataclass -class _ParseState: +class ParseState: depth: int = 0 extras: dict[str, ParseDiagnostic] = field(default_factory=dict) parsers: dict[tuple[str, ...], ArgumentParser] = field(default_factory=dict) @@ -96,7 +96,7 @@ def __init__( supplied_dests: set[str] | None = None, destination_paths: dict[str, tuple[str, ...]] | None = None, diagnostic_layout: DiagnosticLayout | None = None, - parse_state: _ParseState | None = None, + parse_state: ParseState | None = None, command_path: tuple[str, ...] = (), ) -> None: super().__init__( @@ -262,7 +262,7 @@ def __init__( supplied_dests: set[str] | None = None, destination_paths: dict[str, tuple[str, ...]] | None = None, diagnostic_layout: DiagnosticLayout | None = None, - parse_state: _ParseState | None = None, + parse_state: ParseState | None = None, command_path: tuple[str, ...] = (), ) -> None: if parents is None: @@ -321,7 +321,7 @@ def __init__( self.diagnostic_layout = ( deepcopy(diagnostic_layout) if diagnostic_layout else DiagnosticLayout() ) - self._parse_state = parse_state if parse_state is not None else _ParseState() + self._parse_state = parse_state if parse_state is not None else ParseState() self._parse_state.parsers[command_path] = self self._optional_tokens: dict[str, bool] = {} self._unknown_arguments: list[str] = [] @@ -518,6 +518,20 @@ def exit(self, status: int = 0, message: str | None = None) -> NoReturn: raise UsageError((message or "").strip(), usage=self.format_usage()) + def error(self, message: str) -> NoReturn: + """Report native failures without exposing backend messages or rejected values.""" + diagnostic = self._missing_diagnostic() + if diagnostic is None and self._unknown_arguments: + diagnostic = self._parse_state.extras[self._unknown_arguments[0]] + + if diagnostic is None: + diagnostic = ParseDiagnostic( + kind="usage", + command_path=self._help_command_path, + message=message if not self._in_native_parse else "", + ) + self._fail(diagnostic) + def _argument_label(self, action: argparse.Action) -> str: if action.option_strings: return next( @@ -664,20 +678,6 @@ def _fail(self, diagnostic: ParseDiagnostic) -> NoReturn: self._print_message(normalize_diagnostic_text(text), sys.stderr) self.exit(2) - def error(self, message: str) -> NoReturn: - """Report native failures without exposing backend messages or rejected values.""" - diagnostic = self._missing_diagnostic() - if diagnostic is None and self._unknown_arguments: - diagnostic = self._parse_state.extras[self._unknown_arguments[0]] - - if diagnostic is None: - diagnostic = ParseDiagnostic( - kind="usage", - command_path=self._help_command_path, - message=message if not self._in_native_parse else "", - ) - self._fail(diagnostic) - def _get_formatter(self, file: TextIO | None = None) -> argparse.HelpFormatter: # type: ignore[override] if sys.version_info >= (3, 15): formatter = super()._get_formatter(file=file) diff --git a/interfacy/backends/click/adapter.py b/interfacy/backends/click/adapter.py index ad5467c..0d3447e 100644 --- a/interfacy/backends/click/adapter.py +++ b/interfacy/backends/click/adapter.py @@ -113,6 +113,37 @@ def parse(self, args: tuple[str, ...]) -> ParseOutcome: resources=resources.pop_all(), ) + def present_error(self, presentation: NativePresentation) -> NoReturn: + context = presentation.context if isinstance(presentation, ClickPresentation) else None + if context is None: + command = self._native_parser + context = command.context_class( + command, info_name=self._config.program_name or Path(sys.argv[0]).name + ) + for segment in presentation.command_path: + if not isinstance(command, click.Group): + break + + child = command.get_command(context, segment) + if child is None: + break + + command = child + context = command.context_class(command, info_name=segment, parent=context) + + usage = None + diagnostic = presentation.diagnostic + if presentation.kind == "usage": + usage = context.get_usage() + + raise UsageError( + presentation.message, + usage=usage, + diagnostic=diagnostic, + program=context.command_path, + exit_code=presentation.exit_code, + ) + @staticmethod def _diagnostic(error: click.UsageError) -> ParseDiagnostic: context = error.ctx @@ -178,37 +209,6 @@ def _diagnostic(error: click.UsageError) -> ParseDiagnostic: expected=expected, ) - def present_error(self, presentation: NativePresentation) -> NoReturn: - context = presentation.context if isinstance(presentation, ClickPresentation) else None - if context is None: - command = self._native_parser - context = command.context_class( - command, info_name=self._config.program_name or Path(sys.argv[0]).name - ) - for segment in presentation.command_path: - if not isinstance(command, click.Group): - break - - child = command.get_command(context, segment) - if child is None: - break - - command = child - context = command.context_class(command, info_name=segment, parent=context) - - usage = None - diagnostic = presentation.diagnostic - if presentation.kind == "usage": - usage = context.get_usage() - - raise UsageError( - presentation.message, - usage=usage, - diagnostic=diagnostic, - program=context.command_path, - exit_code=presentation.exit_code, - ) - def _attach_help( self, command: click.Command, diff --git a/interfacy/backends/click/parser.py b/interfacy/backends/click/parser.py index 29b284d..5721980 100644 --- a/interfacy/backends/click/parser.py +++ b/interfacy/backends/click/parser.py @@ -33,7 +33,7 @@ def _click_parser_symbol(name: str) -> Any: _normalize_opt: Callable[[str, Any], str] = _click_parser_symbol("_normalize_opt") -class _InterfacyParserArgument(_BaseArgument): +class ParserArgument(_BaseArgument): def process(self, value: Any, state: Any) -> None: try: super().process(value, state) @@ -52,7 +52,7 @@ class InterfacyOptionParser(_BaseOptionParser): """Parse Click options while supporting varargs option values.""" def add_argument(self, obj: click.Argument, dest: str | None, nargs: int = 1) -> None: - self._args.append(_InterfacyParserArgument(obj, dest=dest, nargs=nargs)) + self._args.append(ParserArgument(obj, dest=dest, nargs=nargs)) def parse_args( self, args: list[str] diff --git a/tests/contracts/backends/test_errors.py b/tests/contracts/backends/test_errors.py index 90ae59a..c8263ae 100644 --- a/tests/contracts/backends/test_errors.py +++ b/tests/contracts/backends/test_errors.py @@ -308,15 +308,13 @@ def command() -> None: parser.add_command(command, name="publish") parser.add_command(command, name="inspect") with pytest.raises(SystemExit) as failure: - parser.run(args=["publsih"]) + parser.run(args=["insect"]) captured = capsys.readouterr() assert failure.value.code == 2 assert captured.out == "" assert captured.err == ( - "error: unknown command 'publsih'\n\n" - "Did you mean 'publish'?\n" - "Run 'tool --help' for usage.\n" + "error: unknown command 'insect'\n\nDid you mean 'inspect'?\nRun 'tool --help' for usage.\n" ) @@ -395,6 +393,7 @@ def test_redirected_diagnostics_are_plain_and_wrap_at_terminal_width( environment: dict[str, str], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + script_invocation: None, ) -> None: monkeypatch.setenv("COLUMNS", "38") for name, value in environment.items(): From 8ad2a9b1a8b794ea0c34ea40f0d598d1fd349a41 Mon Sep 17 00:00:00 2001 From: zigai Date: Fri, 16 Oct 2026 01:55:29 +0200 Subject: [PATCH 3/6] chore: remove diagnostic screenshot --- .github/screenshots/parse-diagnostics.png | Bin 29601 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .github/screenshots/parse-diagnostics.png diff --git a/.github/screenshots/parse-diagnostics.png b/.github/screenshots/parse-diagnostics.png deleted file mode 100644 index 4820cb9ea2bb58983867a8b5e10a2c92cf006e47..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29601 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 z

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 From 9cfd236166c2ed2e1b7f727716df3acb53e9b1e5 Mon Sep 17 00:00:00 2001 From: zigai Date: Sat, 17 Oct 2026 01:44:25 +0200 Subject: [PATCH 4/6] fix(cli): preserve structured conversion diagnostics --- interfacy/backends/argparse/adapter.py | 6 ++- interfacy/backends/click/adapter.py | 11 +++-- interfacy/schema/conversion.py | 61 ++++++++++++++++++++++--- tests/contracts/backends/test_errors.py | 50 ++++++++++++++++++++ 4 files changed, 117 insertions(+), 11 deletions(-) diff --git a/interfacy/backends/argparse/adapter.py b/interfacy/backends/argparse/adapter.py index b1d32d8..fcbeb2f 100644 --- a/interfacy/backends/argparse/adapter.py +++ b/interfacy/backends/argparse/adapter.py @@ -24,7 +24,7 @@ from interfacy.declarations.settings import BackendName from interfacy.exceptions import ConfigurationError, ParseDiagnostic, UsageError from interfacy.help.diagnostics import diagnostic_message -from interfacy.schema.conversion import normalize_schema_values +from interfacy.schema.conversion import ArgumentConversionError, normalize_schema_values from interfacy.schema.nodes import ( COMMAND_KEY, SUBCOMMANDS_KEY, @@ -67,7 +67,9 @@ def parse(self, args: tuple[str, ...]) -> ParseOutcome: except ExecutableFlagTriggeredError as e: return ExecutableAction(e.flag) except (ArgparseParseError, argparse.ArgumentError, ValueError) as e: - diagnostic = e.diagnostic if isinstance(e, UsageError) else None + diagnostic = ( + e.diagnostic if isinstance(e, (UsageError, ArgumentConversionError)) else None + ) diagnostic = diagnostic or ParseDiagnostic(kind="usage") return BackendParseFailure( presentation=NativePresentation( diff --git a/interfacy/backends/click/adapter.py b/interfacy/backends/click/adapter.py index 0d3447e..9772f11 100644 --- a/interfacy/backends/click/adapter.py +++ b/interfacy/backends/click/adapter.py @@ -40,7 +40,7 @@ from interfacy.declarations.settings import BackendName from interfacy.exceptions import ConfigurationError, InterfacyExit, ParseDiagnostic, UsageError from interfacy.help.diagnostics import diagnostic_message -from interfacy.schema.conversion import normalize_schema_values +from interfacy.schema.conversion import ArgumentConversionError, normalize_schema_values from interfacy.schema.nodes import ( COMMAND_KEY, Argument, @@ -99,14 +99,19 @@ def parse(self, args: tuple[str, ...]) -> ParseOutcome: partial_namespace=partial, resources=resources.pop_all(), ) - except ValueError: + except ValueError as e: partial = self._partial_namespace(args, resources) - diagnostic = ParseDiagnostic(kind="usage") + diagnostic = ( + e.diagnostic + if isinstance(e, ArgumentConversionError) + else ParseDiagnostic(kind="usage") + ) return BackendParseFailure( presentation=NativePresentation( kind="usage", message=diagnostic_message(diagnostic), diagnostic=diagnostic, + command_path=diagnostic.command_path, exit_code=2, ), partial_namespace=partial, diff --git a/interfacy/schema/conversion.py b/interfacy/schema/conversion.py index 484dcd0..1d1814b 100644 --- a/interfacy/schema/conversion.py +++ b/interfacy/schema/conversion.py @@ -7,14 +7,23 @@ from interfacy.schema.nodes import Argument, Command, ParserSchema +from interfacy.exceptions import ParseDiagnostic +from interfacy.schema.nodes import ArgumentKind from interfacy.schema.values import RepeatedValue, as_sequence, plan_requires_post_conversion +class ArgumentConversionError(ValueError): + def __init__(self, diagnostic: ParseDiagnostic) -> None: + self.diagnostic = diagnostic + super().__init__(f"Invalid value for {diagnostic.argument!r}") + + def _normalize_argument_value( argument: Argument, bucket: dict[str, Any], *, type_parser: StrToTypeParser | None, + command_path: tuple[str, ...], ) -> None: if argument.name not in bucket or bucket[argument.name] is None: return @@ -29,14 +38,38 @@ def _normalize_argument_value( return - bucket[argument.name] = value_plan.convert(bucket[argument.name], type_parser=type_parser) + try: + bucket[argument.name] = value_plan.convert(bucket[argument.name], type_parser=type_parser) + except (TypeError, ValueError, KeyError): + pass + else: + return + + label = argument.metavar or argument.display_name.replace("_", "-").upper() + if argument.kind is ArgumentKind.OPTION: + label = next((flag for flag in argument.flags if flag.startswith("--")), argument.flags[0]) + + raise ArgumentConversionError( + ParseDiagnostic( + kind="invalid_choice" if argument.choices is not None else "invalid_type", + command_path=command_path, + argument=label, + choices=tuple(str(choice) for choice in argument.choices or ()), + ) + ) def normalize_argument_values( - command: Command, bucket: dict[str, Any], *, type_parser: StrToTypeParser | None + command: Command, + bucket: dict[str, Any], + *, + type_parser: StrToTypeParser | None, + command_path: tuple[str, ...], ) -> None: for argument in (*command.initializer, *command.arguments): - _normalize_argument_value(argument, bucket, type_parser=type_parser) + _normalize_argument_value( + argument, bucket, type_parser=type_parser, command_path=command_path + ) if not command.subcommands: return @@ -47,7 +80,12 @@ def normalize_argument_values( sub_bucket = bucket.get(sub_cmd.cli_name) if isinstance(sub_bucket, dict): - normalize_argument_values(sub_cmd, sub_bucket, type_parser=type_parser) + normalize_argument_values( + sub_cmd, + sub_bucket, + type_parser=type_parser, + command_path=(*command_path, sub_cmd.canonical_name), + ) def normalize_schema_values( @@ -60,17 +98,28 @@ def normalize_schema_values( namespace.get(command.canonical_name) if command.command_type == "group" else namespace ) if isinstance(bucket, dict): - normalize_argument_values(command, bucket, type_parser=type_parser) + normalize_argument_values( + command, + bucket, + type_parser=type_parser, + command_path=(command.canonical_name,) if command.command_type == "group" else (), + ) return for command in schema.commands.values(): bucket = namespace.get(command.canonical_name) if isinstance(bucket, dict): - normalize_argument_values(command, bucket, type_parser=type_parser) + normalize_argument_values( + command, + bucket, + type_parser=type_parser, + command_path=(command.canonical_name,), + ) __all__ = [ + "ArgumentConversionError", "normalize_argument_values", "normalize_schema_values", ] diff --git a/tests/contracts/backends/test_errors.py b/tests/contracts/backends/test_errors.py index c8263ae..368fa34 100644 --- a/tests/contracts/backends/test_errors.py +++ b/tests/contracts/backends/test_errors.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, Field from interfacy import ( + CommandGroup, ConfigurationError, DiagnosticLayout, ExitCode, @@ -294,6 +295,55 @@ def command(count: int) -> None: assert "private-input" not in str(failure.value) +@pytest.mark.parametrize( + ("style", "values", "argument"), + [ + ("required_positional", ["rejected-value", "label"], "PAIR"), + ("keyword_only", ["--pair", "rejected-value", "label"], "--pair"), + ], +) +def test_nested_structured_conversion_reports_leaf_diagnostic( + parser: Interfacy, + style: str, + values: list[str], + argument: str, + capsys: pytest.CaptureFixture[str], + script_invocation: None, +) -> None: + parser.apply_setup(flag_strategy=DefaultFlagStrategy(style=style)) + + def upload_item(pair: tuple[int, str]) -> None: + pytest.fail("Rejected input must not execute") + + project = CommandGroup("project") + project.add_command(upload_item) + parser.add_command(project) + args = ["project", "upload-item", *values] + + with pytest.raises(UsageError) as failure: + parser.invoke(args=args) + + diagnostic = failure.value.diagnostic + assert diagnostic is not None + assert diagnostic.kind == "invalid_type" + assert diagnostic.command_path == ("project", "upload-item") + assert diagnostic.argument == argument + assert "rejected-value" not in str(failure.value) + assert "tool project upload-item" in failure.value.usage + assert capsys.readouterr() == ("", "") + + with pytest.raises(SystemExit) as failure: + parser.run(args=args) + + captured = capsys.readouterr() + assert failure.value.code == 2 + assert captured.out == "" + assert f"error: {argument!r} requires a valid value" in captured.err + assert "usage: tool project upload-item" in captured.err + assert "Run 'tool project upload-item --help'" in captured.err + assert "rejected-value" not in captured.err + + def test_unknown_command_is_concise_and_suggests_without_executing( parser: Interfacy, monkeypatch: pytest.MonkeyPatch, From 1b1043beac1d4d2872f96253fff18c8e662edfbb Mon Sep 17 00:00:00 2001 From: zigai Date: Sat, 17 Oct 2026 01:44:26 +0200 Subject: [PATCH 5/6] fix(cli): exclude positionals from option suggestions --- interfacy/core/diagnostics.py | 5 ++++- tests/contracts/backends/test_help_errors.py | 22 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/interfacy/core/diagnostics.py b/interfacy/core/diagnostics.py index a848d89..beec4f5 100644 --- a/interfacy/core/diagnostics.py +++ b/interfacy/core/diagnostics.py @@ -21,7 +21,7 @@ from interfacy.help.layouts import HelpLayout from interfacy.help.renderer import SchemaHelpRenderer from interfacy.plugins import DiagnosticHookContext, SchemaDescriptor -from interfacy.schema.nodes import Command, ParserSchema, find_command +from interfacy.schema.nodes import ArgumentKind, Command, ParserSchema, find_command def render_diagnostic( @@ -112,6 +112,9 @@ def option_flags(schema: ParserSchema, command_path: tuple[str, ...]) -> tuple[s for command in chain: for argument in (*command.initializer, *command.arguments): + if argument.kind is not ArgumentKind.OPTION: + continue + flags.extend(argument.flags) if argument.boolean_behavior is not None: flags.extend(argument.boolean_behavior.positive_flags) diff --git a/tests/contracts/backends/test_help_errors.py b/tests/contracts/backends/test_help_errors.py index c895e51..505d8f3 100644 --- a/tests/contracts/backends/test_help_errors.py +++ b/tests/contracts/backends/test_help_errors.py @@ -132,6 +132,28 @@ def status(self) -> None: assert "Did you mean '--verbose'?" in capsys.readouterr().err +@pytest.mark.parametrize("backend", ["argparse", "click"]) +@pytest.mark.parametrize( + ("option", "suggestions"), + [("--count", []), ("--verbsoe", ["Did you mean '--verbose'?"])], +) +def test_unknown_option_suggestions_exclude_positional_names(backend, option, suggestions, capsys): + def command(count: int, *, verbose: bool = False) -> None: + pytest.fail("Rejected input must not execute") + + parser = Interfacy(backend=backend) + with pytest.raises(SystemExit) as failure: + parser.run(command, args=["1", option]) + + captured = capsys.readouterr() + assert failure.value.code == 2 + assert captured.out == "" + assert f"unknown option {option!r}" in captured.err + assert [line for line in captured.err.splitlines() if line.startswith("Did you mean")] == ( + suggestions + ) + + @pytest.mark.parametrize("backend", ["argparse", "click"]) def test_diagnostic_usage_reuses_the_configured_help_layout(backend, capsys, script_invocation): def command(*, workers: int = 1) -> None: From fa11e7a9e9e0528806635383f267e7d31068698f Mon Sep 17 00:00:00 2001 From: zigai Date: Sat, 17 Oct 2026 01:44:26 +0200 Subject: [PATCH 6/6] build(deps): upgrade Rattle development tooling --- .github/workflows/lint.yml | 2 +- Justfile | 2 +- pyproject.toml | 5 ++- uv.lock | 80 ++++++++++++++------------------------ 4 files changed, 36 insertions(+), 53 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d7de85a..724b0b4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -34,4 +34,4 @@ jobs: run: uv run --group dev codespell - name: Run Rattle lint - run: uv run --group dev rattle lint . + run: uv run --group dev rattle check . diff --git a/Justfile b/Justfile index c1ea501..747de49 100644 --- a/Justfile +++ b/Justfile @@ -114,7 +114,7 @@ spell: _require-uv # Run all quality checks check: lint test coverage typecheck spell uv run --group dev ruff format --check . - uv run --group dev rattle lint . + uv run --group dev rattle check . # List available recipes help: diff --git a/pyproject.toml b/pyproject.toml index dce34f7..beec6f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -291,6 +291,9 @@ convention = "google" [tool.uv] default-groups = ["test"] +[tool.uv.dependency-groups] +dev = {requires-python = ">=3.11"} + [dependency-groups] test = ["pytest>=9.0.3", "pytest-mock", "coverage>=7.14.1", "click>=8.3.1", "pydantic>=2,<3", "argcomplete"] -dev = [{include-group = "test"}, "ruff==0.16.10", "pre-commit>=4.6.0", "pyrefly>=1.0.0", "argcomplete", "rattle-lint>=3.2.0", "codespell>=2.4.2"] +dev = [{include-group = "test"}, "ruff==0.16.10", "pre-commit>=4.6.0", "pyrefly>=1.0.0", "argcomplete", "rattle-lint>=3.4.0", "codespell>=2.4.2"] diff --git a/uv.lock b/uv.lock index 89d8a3f..0830453 100644 --- a/uv.lock +++ b/uv.lock @@ -377,29 +377,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] -[[package]] -name = "filelock" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/4c/58/6fd434bec86eff7c38a3168454cb132b762b2bea9b3ac094101a2f7bc32a/filelock-4.1.0.tar.gz", hash = "sha256:ad7f724afef953e731b1cc39bcd3a09166d72ed7fcdf29e6e88b1c3235c6715d", size = 561277, upload-time = "2026-10-09T19:57:20.34Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/86/032133892a5de43b5a98200b01aadcad68cc255e274a762f08b8a76d2912/filelock-4.1.0-py3-none-any.whl", hash = "sha256:2ce9818e3e2d8f284c1a964414447ef148d42a5fd5e2a477a7118e574b293ec1", size = 133003, upload-time = "2026-10-09T19:57:18.716Z" }, -] - [[package]] name = "filelock" version = "4.2.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version >= '3.11.5' and python_full_version < '3.12'", - "python_full_version >= '3.11' and python_full_version < '3.11.5'", -] sdist = { url = "https://files.pythonhosted.org/packages/f4/a9/1af41b37c3279712b22cdc63aac78a52432202b6fe1f9666a2a3d2831fb4/filelock-4.2.0.tar.gz", hash = "sha256:7a60906c75227cf04d0c273afadc8219400f11aeb13cc69591d4f6cdc6c8036e", size = 569667, upload-time = "2026-10-14T20:57:13.11Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8e/a3/9bc26acff301fe1aaea1cc3d82a1d57e0a34df3e1cadbfa91ac2dbcdde5c/filelock-4.2.0-py3-none-any.whl", hash = "sha256:2ff5690882e8cdb00ef31fb3d01a3094c29f30985426c59495afb1733f3b7238", size = 135032, upload-time = "2026-10-14T20:57:11.349Z" }, @@ -496,17 +477,17 @@ full = [ [package.dev-dependencies] dev = [ - { name = "argcomplete" }, - { name = "click" }, - { name = "codespell" }, - { name = "coverage" }, - { name = "pre-commit" }, - { name = "pydantic" }, - { name = "pyrefly" }, - { name = "pytest" }, - { name = "pytest-mock" }, - { name = "rattle-lint" }, - { name = "ruff" }, + { name = "argcomplete", marker = "python_full_version >= '3.11'" }, + { name = "click", marker = "python_full_version >= '3.11'" }, + { name = "codespell", marker = "python_full_version >= '3.11'" }, + { name = "coverage", marker = "python_full_version >= '3.11'" }, + { name = "pre-commit", marker = "python_full_version >= '3.11'" }, + { name = "pydantic", marker = "python_full_version >= '3.11'" }, + { name = "pyrefly", marker = "python_full_version >= '3.11'" }, + { name = "pytest", marker = "python_full_version >= '3.11'" }, + { name = "pytest-mock", marker = "python_full_version >= '3.11'" }, + { name = "rattle-lint", marker = "python_full_version >= '3.11'" }, + { name = "ruff", marker = "python_full_version >= '3.11'" }, ] test = [ { name = "argcomplete" }, @@ -517,6 +498,9 @@ test = [ { name = "pytest-mock" }, ] +[package.group-requires-python] +dev = ">=3.11" + [package.metadata] requires-dist = [ { name = "argcomplete", marker = "extra == 'full'" }, @@ -545,17 +529,17 @@ provides-extras = ["full", "click", "docs"] [package.metadata.requires-dev] dev = [ - { name = "argcomplete" }, - { name = "click", specifier = ">=8.3.1" }, - { name = "codespell", specifier = ">=2.4.2" }, - { name = "coverage", specifier = ">=7.14.1" }, - { name = "pre-commit", specifier = ">=4.6.0" }, - { name = "pydantic", specifier = ">=2,<3" }, - { name = "pyrefly", specifier = ">=1.0.0" }, - { name = "pytest", specifier = ">=9.0.3" }, - { name = "pytest-mock" }, - { name = "rattle-lint", specifier = ">=3.2.0" }, - { name = "ruff", specifier = "==0.16.10" }, + { name = "argcomplete", marker = "python_full_version >= '3.11'" }, + { name = "click", marker = "python_full_version >= '3.11'", specifier = ">=8.3.1" }, + { name = "codespell", marker = "python_full_version >= '3.11'", specifier = ">=2.4.2" }, + { name = "coverage", marker = "python_full_version >= '3.11'", specifier = ">=7.14.1" }, + { name = "pre-commit", marker = "python_full_version >= '3.11'", specifier = ">=4.6.0" }, + { name = "pydantic", marker = "python_full_version >= '3.11'", specifier = ">=2,<3" }, + { name = "pyrefly", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0" }, + { name = "pytest", marker = "python_full_version >= '3.11'", specifier = ">=9.0.3" }, + { name = "pytest-mock", marker = "python_full_version >= '3.11'" }, + { name = "rattle-lint", marker = "python_full_version >= '3.11'", specifier = ">=3.4.0" }, + { name = "ruff", marker = "python_full_version >= '3.11'", specifier = "==0.16.10" }, ] test = [ { name = "argcomplete" }, @@ -1131,8 +1115,7 @@ name = "python-discovery" version = "1.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "filelock", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "filelock" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b0/73/54993df8fc57e906dc9b7e01d1d9b0b2d3eb0ce3190639e33dccb12853f7/python_discovery-1.6.2.tar.gz", hash = "sha256:cd1738ca1d37c86ef9d0b654dd46fcee1e41c97c6add12575e8501ced39afdb4", size = 86921, upload-time = "2026-10-10T01:24:27.912Z" } wheels = [ @@ -1283,7 +1266,7 @@ wheels = [ [[package]] name = "rattle-lint" -version = "3.2.0" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "interfacy" }, @@ -1292,12 +1275,11 @@ dependencies = [ { name = "msgspec" }, { name = "packaging" }, { name = "platformdirs" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "trailrunner" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/b5/6506759e7a9684f0ec8b504bb6528dd23d9472c3504230307ab71a0e4040/rattle_lint-3.2.0.tar.gz", hash = "sha256:88c71765fafbd455da8d6bc373c18af601f42251888f4770b95675a3336c79af", size = 342607, upload-time = "2026-10-11T21:18:25.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/e7/6a747a2b16fcb02bc6d2740706f612c44a66f8d7f0704e7255e9d036d77d/rattle_lint-3.4.0.tar.gz", hash = "sha256:dec9c704d22557bee42c1e3124a85710884cc6438cdd517199dd81eb0544e725", size = 330238, upload-time = "2026-10-16T08:31:33.61Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/6c/db1d9e3f48fa17f33ee73ce3119908ededf1db166b7e03143d007bc0eea4/rattle_lint-3.2.0-py3-none-any.whl", hash = "sha256:e1ee78336c82ee98064c6d2191dc00a6ca7e6298bec619b762cb3561ea0194a7", size = 213048, upload-time = "2026-10-11T21:18:23.784Z" }, + { url = "https://files.pythonhosted.org/packages/d8/60/a986d5af7b3d89d6e294e94910f4ec0cef3d845e36b8fe50a73cb09ff9da/rattle_lint-3.4.0-py3-none-any.whl", hash = "sha256:f3ce08665bad1d8fe289caaef20d6f8f7d9813f5e0a687a5b86716aef8a783f2", size = 212388, upload-time = "2026-10-16T08:31:32.122Z" }, ] [[package]] @@ -1749,12 +1731,10 @@ version = "21.14.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, - { name = "filelock", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "filelock", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "filelock" }, { name = "packaging" }, { name = "platformdirs" }, { name = "python-discovery" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/f9/f323b3b6058cff3853b31cf6a49c0425ed797bf9611572ec10d065e08bac/virtualenv-21.14.5.tar.gz", hash = "sha256:c4cb6c13e46b57225a999c7e22a09b163393878facc7ac4c059a57f46faa1647", size = 5467127, upload-time = "2026-10-02T17:42:25.613Z" } wheels = [