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/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..fcbeb2f 100644 --- a/interfacy/backends/argparse/adapter.py +++ b/interfacy/backends/argparse/adapter.py @@ -22,8 +22,9 @@ 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.schema.conversion import normalize_schema_values +from interfacy.exceptions import ConfigurationError, ParseDiagnostic, UsageError +from interfacy.help.diagnostics import diagnostic_message +from interfacy.schema.conversion import ArgumentConversionError, normalize_schema_values from interfacy.schema.nodes import ( COMMAND_KEY, SUBCOMMANDS_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,16 @@ 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, ArgumentConversionError)) 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 +87,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 +131,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 +152,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..eeb2bf1 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)), @@ -455,19 +519,164 @@ 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() + """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( + (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) - if self._interfacy_raise_parse_errors: - raise ArgparseParseError(message, usage=usage) + 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) - if not self.sys_exit_enabled: - raise UsageError(message, usage=usage) + 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 - self._print_message(f"{usage.rstrip()}\n", sys.stderr) - self.exit(2, f"{self.prog}: error: {message}\n") + return self._fail(ParseDiagnostic(kind="usage", command_path=self._help_command_path)) + + 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)) + + 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 _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..9772f11 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,12 +28,19 @@ 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.schema.conversion import normalize_schema_values +from interfacy.exceptions import ConfigurationError, InterfacyExit, ParseDiagnostic, UsageError +from interfacy.help.diagnostics import diagnostic_message +from interfacy.schema.conversion import ArgumentConversionError, normalize_schema_values from interfacy.schema.nodes import ( COMMAND_KEY, Argument, @@ -74,23 +84,34 @@ 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: partial = self._partial_namespace(args, resources) + diagnostic = ( + e.diagnostic + if isinstance(e, ArgumentConversionError) + else 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=partial, @@ -98,11 +119,100 @@ def parse(self, args: tuple[str, ...]) -> ParseOutcome: ) 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)) + 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 + 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() + ) - raise UsageError(presentation.message, usage=usage) + 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 _attach_help( self, @@ -231,6 +341,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 +502,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 +544,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 +628,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..5721980 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 ParserArgument(_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(ParserArgument(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..beec4f5 --- /dev/null +++ b/interfacy/core/diagnostics.py @@ -0,0 +1,128 @@ +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 ArgumentKind, 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): + 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) + 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/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/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/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..368fa34 100644 --- a/tests/contracts/backends/test_errors.py +++ b/tests/contracts/backends/test_errors.py @@ -1,9 +1,17 @@ -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 ( + CommandGroup, + ConfigurationError, + DiagnosticLayout, + ExitCode, + Interfacy, + InterfacyError, + UsageError, +) from interfacy.naming import DefaultFlagStrategy from interfacy.plugins import AfterParseContext, InterfacyPlugin @@ -237,3 +245,218 @@ 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) + + +@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, + 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=["insect"]) + + captured = capsys.readouterr() + assert failure.value.code == 2 + assert captured.out == "" + assert captured.err == ( + "error: unknown command 'insect'\n\nDid you mean 'inspect'?\nRun '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], + script_invocation: None, +) -> 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..505d8f3 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,157 @@ 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"]) +@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: + 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") 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 = [