Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
2 changes: 1 addition & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/backends/argparse-wrapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/backends/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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

Expand Down
6 changes: 6 additions & 0 deletions interfacy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
Expand Down Expand Up @@ -66,6 +70,7 @@ def __getattr__(name: str) -> Any:
"BooleanMode",
"CommandGroup",
"ConfigurationError",
"DiagnosticLayout",
"DuplicateCommandError",
"DuplicatePluginError",
"ExecutableFlag",
Expand All @@ -76,6 +81,7 @@ def __getattr__(name: str) -> Any:
"InterfacyError",
"InvalidCommandError",
"Param",
"ParseDiagnostic",
"ParsedArguments",
"PipeInputError",
"ReservedFlagError",
Expand Down
4 changes: 4 additions & 0 deletions interfacy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 24 additions & 4 deletions interfacy/backends/argparse/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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, ())
Expand All @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Expand Down
50 changes: 2 additions & 48 deletions interfacy/backends/argparse/errors.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading