From 09eeb90dc6868e5347461221ee7c4c53c465bb5f Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Mon, 2 Jun 2025 23:45:37 -0400 Subject: [PATCH] Bubble up errors from CAP, catch a broader exception when parsing arguments, add type parsing to arg_metadata --- examples/type_validation.py | 100 ++++++++++++++++++++++++++++++++++++ falyx/falyx.py | 6 +-- falyx/parsers/argparse.py | 48 ++++++++--------- falyx/parsers/signature.py | 15 ++++-- falyx/version.py | 2 +- pyproject.toml | 2 +- 6 files changed, 138 insertions(+), 35 deletions(-) create mode 100644 examples/type_validation.py diff --git a/examples/type_validation.py b/examples/type_validation.py new file mode 100644 index 0000000..5d9418d --- /dev/null +++ b/examples/type_validation.py @@ -0,0 +1,100 @@ +import asyncio +from uuid import UUID, uuid4 + +from falyx import Falyx +from falyx.parsers import CommandArgumentParser + +flx = Falyx("Test Type Validation") + + +def uuid_val(value: str) -> str: + """Custom validator to ensure a string is a valid UUID.""" + UUID(value) + return value + + +async def print_uuid(uuid: str) -> str: + """Prints the UUID if valid.""" + print(f"Valid UUID: {uuid}") + return uuid + + +flx.add_command( + "U", + "Print a valid UUID (arguemnts)", + print_uuid, + arguments=[ + { + "flags": ["uuid"], + "type": uuid_val, + "help": "A valid UUID string", + } + ], +) + + +def uuid_parser(parser: CommandArgumentParser) -> None: + """Custom parser to ensure the UUID argument is valid.""" + parser.add_argument( + "uuid", + type=uuid_val, + help="A valid UUID string", + ) + + +flx.add_command( + "I", + "Print a valid UUID (argument_config)", + print_uuid, + argument_config=uuid_parser, +) + +flx.add_command( + "D", + "Print a valid UUID (arg_metadata)", + print_uuid, + arg_metadata={ + "uuid": { + "type": uuid_val, + "help": "A valid UUID string", + } + }, +) + + +def custom_parser(arguments: list[str]) -> tuple[tuple, dict]: + """Custom parser to ensure the UUID argument is valid.""" + if len(arguments) != 1: + raise ValueError("Exactly one argument is required") + uuid_val(arguments[0]) + return (arguments[0],), {} + + +flx.add_command( + "C", + "Print a valid UUID (custom_parser)", + print_uuid, + custom_parser=custom_parser, +) + + +async def generate_uuid() -> str: + """Generates a new UUID.""" + new_uuid = uuid4() + print(f"Generated UUID: {new_uuid}") + return new_uuid + + +flx.add_command( + "G", + "Generate a new UUID", + lambda: print(uuid4()), +) + + +async def main() -> None: + await flx.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/falyx/falyx.py b/falyx/falyx.py index 6137d2d..5dcfea6 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -746,12 +746,10 @@ class Falyx: """ table = Table(title=self.title, show_header=False, box=box.SIMPLE) # type: ignore[arg-type] visible_commands = [item for item in self.commands.items() if not item[1].hidden] - space = self.console.width // self.columns for chunk in chunks(visible_commands, self.columns): row = [] for key, command in chunk: - cell = f"[{key}] [{command.style}]{command.description}" - row.append(f"{cell:<{space}}") + row.append(f"[{key}] [{command.style}]{command.description}") table.add_row(*row) bottom_row = self.get_bottom_row() for row in chunks(bottom_row, self.columns): @@ -811,7 +809,7 @@ class Falyx: args, kwargs = await name_map[choice].parse_args( input_args, from_validate ) - except CommandArgumentError as error: + except (CommandArgumentError, Exception) as error: if not from_validate: name_map[choice].show_help() self.console.print(f"[{OneColors.DARK_RED}]❌ [{choice}]: {error}") diff --git a/falyx/parsers/argparse.py b/falyx/parsers/argparse.py index 0c1a581..5b7adec 100644 --- a/falyx/parsers/argparse.py +++ b/falyx/parsers/argparse.py @@ -292,10 +292,10 @@ class CommandArgumentParser: if not isinstance(choice, expected_type): try: coerce_value(choice, expected_type) - except Exception: + except Exception as error: raise CommandArgumentError( - f"Invalid choice {choice!r}: not coercible to {expected_type.__name__}" - ) + f"Invalid choice {choice!r}: not coercible to {expected_type.__name__} error: {error}" + ) from error return choices def _validate_default_type( @@ -305,10 +305,10 @@ class CommandArgumentParser: if default is not None and not isinstance(default, expected_type): try: coerce_value(default, expected_type) - except Exception: + except Exception as error: raise CommandArgumentError( - f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}" - ) + f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}" + ) from error def _validate_default_list_type( self, default: list[Any], expected_type: type, dest: str @@ -318,10 +318,10 @@ class CommandArgumentParser: if not isinstance(item, expected_type): try: coerce_value(item, expected_type) - except Exception: + except Exception as error: raise CommandArgumentError( - f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}" - ) + f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}" + ) from error def _validate_resolver( self, action: ArgumentAction, resolver: BaseAction | None @@ -597,10 +597,10 @@ class CommandArgumentParser: try: typed = [coerce_value(value, spec.type) for value in values] - except Exception: + except Exception as error: raise CommandArgumentError( - f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" - ) + f"Invalid value for '{spec.dest}': {error}" + ) from error if spec.action == ArgumentAction.ACTION: assert isinstance( spec.resolver, BaseAction @@ -684,10 +684,10 @@ class CommandArgumentParser: typed_values = [ coerce_value(value, spec.type) for value in values ] - except ValueError: + except ValueError as error: raise CommandArgumentError( - f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" - ) + f"Invalid value for '{spec.dest}': {error}" + ) from error try: result[spec.dest] = await spec.resolver(*typed_values) except Exception as error: @@ -715,10 +715,10 @@ class CommandArgumentParser: typed_values = [ coerce_value(value, spec.type) for value in values ] - except ValueError: + except ValueError as error: raise CommandArgumentError( - f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" - ) + f"Invalid value for '{spec.dest}': {error}" + ) from error if spec.nargs is None: result[spec.dest].append(spec.type(values[0])) else: @@ -732,10 +732,10 @@ class CommandArgumentParser: typed_values = [ coerce_value(value, spec.type) for value in values ] - except ValueError: + except ValueError as error: raise CommandArgumentError( - f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" - ) + f"Invalid value for '{spec.dest}': {error}" + ) from error result[spec.dest].extend(typed_values) consumed_indices.update(range(i, new_i)) i = new_i @@ -745,10 +745,10 @@ class CommandArgumentParser: typed_values = [ coerce_value(value, spec.type) for value in values ] - except ValueError: + except ValueError as error: raise CommandArgumentError( - f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" - ) + f"Invalid value for '{spec.dest}': {error}" + ) from error if not typed_values and spec.nargs not in ("*", "?"): raise CommandArgumentError( f"Expected at least one value for '{spec.dest}'" diff --git a/falyx/parsers/signature.py b/falyx/parsers/signature.py index 3382d92..525c70f 100644 --- a/falyx/parsers/signature.py +++ b/falyx/parsers/signature.py @@ -31,11 +31,16 @@ def infer_args_from_func( ): continue - arg_type = ( - param.annotation if param.annotation is not inspect.Parameter.empty else str - ) - if isinstance(arg_type, str): - arg_type = str + if metadata.get("type"): + arg_type = metadata["type"] + else: + arg_type = ( + param.annotation + if param.annotation is not inspect.Parameter.empty + else str + ) + if isinstance(arg_type, str): + arg_type = str default = param.default if param.default is not inspect.Parameter.empty else None is_required = param.default is inspect.Parameter.empty if is_required: diff --git a/falyx/version.py b/falyx/version.py index 1d70f6b..d95a92e 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.47" +__version__ = "0.1.48" diff --git a/pyproject.toml b/pyproject.toml index b4e14e8..9f2f095 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.47" +version = "0.1.48" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT"