Skip to content

Commit 774bf78

Browse files
authored
Merge pull request #8 from explosion/error-maps
2 parents 8241787 + 9a09350 commit 774bf78

File tree

4 files changed

+148
-20
lines changed

4 files changed

+148
-20
lines changed

README.md

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# radicli: Radically lightweight command-line interfaces
44

5-
`radicli` is a small, zero-dependency Python package for creating command line interfaces, built on top of Python's [`argparse`](https://docs.python.org/3/library/argparse.html) module. It introduces minimal overhead, preserves your original Python functions and uses type hints to parse values provided on the CLI. It supports all common types out-of-the-box, including complex ones like `List[str]`, `Literal` and `Enum`, and allows registering custom types with custom converters.
5+
`radicli` is a small, zero-dependency Python package for creating command line interfaces, built on top of Python's [`argparse`](https://docs.python.org/3/library/argparse.html) module. It introduces minimal overhead, preserves your original Python functions and uses type hints to parse values provided on the CLI. It supports all common types out-of-the-box, including complex ones like `List[str]`, `Literal` and `Enum`, and allows registering custom types with custom converters, as well as custom CLI-only error handling.
66

77
> **Important note:** This package aims to be a simple option based on the requirements of our libraries. If you're looking for a more full-featured CLI toolkit, check out [`typer`](https://typer.tiangolo.com), [`click`](https://click.palletsprojects.com) or [`plac`](https://plac.readthedocs.io/en/latest/).
88
@@ -234,6 +234,56 @@ $ python cli.py hey --name Alex --age 35
234234
$ python cli.py greet person --name Alex --age 35
235235
```
236236

237+
### Error handling
238+
239+
One common problem when adding CLIs to a code base is error handling. When called in a CLI context, you typically want to pretty-print any errors and avoid long tracebacks. However, you don't want to use those errors and plain `SystemExit`s with no traceback in helper functions that are used in other places, or when the CLI functions are called directly from Python or during testing.
240+
241+
To solve this, `radicli` lets you provide an error map via the `errors` argument on initialization. It maps `Exception` types like `ValueError` or fully custom error subclasses to handler functions. If an error of that type is raised, the handler is called and will receive the error. The handler can optionally return an exit code – in this case, `radicli` will perform a `sys.exit` using that code. If no error code is returned, no exit is performed and the handler can either take care of the exiting itself or choose to not exit.
242+
243+
```python
244+
from radicli import Radicli
245+
from termcolor import colored
246+
247+
def pretty_print_error(error: Exception) -> int:
248+
print(colored(f"🚨 {error}", "red"))
249+
return 1
250+
251+
cli = Radicli(errors={ValueError: handle_error})
252+
253+
@cli.command("hello", name=Arg("--name"))
254+
def hello(name: str):
255+
if name == "Alex":
256+
raise ValueError("Invalid name")
257+
```
258+
259+
```
260+
$ python cli.py hello --name Alex
261+
🚨 Invalid name
262+
```
263+
264+
```bash
265+
>>> hello("Alex")
266+
Traceback (most recent call last):
267+
File "<stdin>", line 1, in <module>
268+
ValueError: Invalid name
269+
```
270+
271+
This approach is especially powerful with custom error subclasses. Here you can decide which arguments the error should take and how this information should be displayed on the CLI vs. in a regular non-CLI context.
272+
273+
```python
274+
class CustomError(Exception):
275+
def __init__(self, text: str, additional_info: Any = "") -> None:
276+
self.text = text
277+
self.additional_info
278+
self.message = f"{self.text} {self.additional_info}"
279+
super().__init__(self.message)
280+
281+
def handle_custom_error(error: CustomError) -> int:
282+
print(colored(error.text, "red"))
283+
print(error.additiona_info)
284+
return 1
285+
```
286+
237287
## 🎛 API
238288

239289
### <kbd>dataclass</kbd> `Arg`
@@ -266,14 +316,15 @@ Internal representation of a CLI command. Can be accessed via `Radicli.commands`
266316

267317
#### Attributes
268318

269-
| Name | Type | Description |
270-
| ------------- | ---------------------------------- | -------------------------------------------------------------------------------------- |
271-
| `prog` | `Optional[str]` | Program name displayed in `--help` prompt usage examples, e.g. `"python -m spacy"`. |
272-
| `help` | `str` | Help text for the CLI, displayed in top-level `--help`. Defaults to `""`. |
273-
| `version` | `Optional[str]` | Version available via `--version`, if set. |
274-
| `converters` | `Dict[Type, Callable[[str], Any]]` | Dict mapping types to global converter functions. |
275-
| `commands` | `Dict[str, Command]` | The commands added to the CLI, keyed by name. |
276-
| `subcommands` | `Dict[str, Dict[str, Command]]` | The subcommands added to the CLI, keyed by parent name, then keyed by subcommand name. |
319+
| Name | Type | Description |
320+
| ------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
321+
| `prog` | `Optional[str]` | Program name displayed in `--help` prompt usage examples, e.g. `"python -m spacy"`. |
322+
| `help` | `str` | Help text for the CLI, displayed in top-level `--help`. Defaults to `""`. |
323+
| `version` | `Optional[str]` | Version available via `--version`, if set. |
324+
| `converters` | `Dict[Type, Callable[[str], Any]]` | Dict mapping types to global converter functions. |
325+
| `errors` | `Dict[Type[Exception], Callable[[Exception], Optional[int]]]` | Dict mapping errors types to global error handlers. If the handler returns an exit code, a `sys.exit` will be raised using that code. See [error handling](#error-handling) for details. |
326+
| `commands` | `Dict[str, Command]` | The commands added to the CLI, keyed by name. |
327+
| `subcommands` | `Dict[str, Dict[str, Command]]` | The subcommands added to the CLI, keyed by parent name, then keyed by subcommand name. |
277328

278329
#### <kbd>method</kbd> `Radicli.__init__`
279330

@@ -285,13 +336,14 @@ from radicli import Radicli
285336
cli = Radicli(prog="python -m spacy")
286337
```
287338

288-
| Argument | Type | Description |
289-
| ------------ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
290-
| `prog` | `Optional[str]` | Program name displayed in `--help` prompt usage examples, e.g. `"python -m spacy"`. |
291-
| `help` | `str` | Help text for the CLI, displayed in top-level `--help`. Defaults to `""`. |
292-
| `version` | `Optional[str]` | Version available via `--version`, if set. |
293-
| `converters` | `Dict[Type, Callable[[str], Any]]` | Dict mapping types to converter functions. All arguments with these types will then be passed to the respective converter. |
294-
| `extra_key` | `str` | Name of function argument that receives extra arguments if the `command_with_extra` or `subcommand_with_extra` decorator is used. Defaults to `"_extra"`. |
339+
| Argument | Type | Description |
340+
| ------------ | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
341+
| `prog` | `Optional[str]` | Program name displayed in `--help` prompt usage examples, e.g. `"python -m spacy"`. |
342+
| `help` | `str` | Help text for the CLI, displayed in top-level `--help`. Defaults to `""`. |
343+
| `version` | `Optional[str]` | Version available via `--version`, if set. |
344+
| `converters` | `Dict[Type, Callable[[str], Any]]` | Dict mapping types to converter functions. All arguments with these types will then be passed to the respective converter. |
345+
| `errors` | `Dict[Type[Exception], Callable[[Exception], Optional[int]]]` | Dict mapping errors types to global error handlers. If the handler returns an exit code, a `sys.exit` will be raised using that code. See [error handling](#error-handling) for details. |
346+
| `extra_key` | `str` | Name of function argument that receives extra arguments if the `command_with_extra` or `subcommand_with_extra` decorator is used. Defaults to `"_extra"`. |
295347

296348
#### <kbd>decorator</kbd> `Radicli.command`, `Radicli.command_with_extra`
297349

radicli/__init__.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55

66
from .parser import ArgumentParser, HelpFormatter
77
from .util import Arg, ArgparseArg, get_arg, join_strings, format_type, format_table
8-
from .util import format_arg_help, SimpleFrozenDict, CommandNotFoundError
9-
from .util import CliParserError, CommandExistsError, ConvertersType
8+
from .util import format_arg_help, expand_error_subclasses, SimpleFrozenDict
9+
from .util import CommandNotFoundError, CliParserError, CommandExistsError
10+
from .util import ConvertersType, ErrorHandlersType
1011
from .util import DEFAULT_CONVERTERS, DEFAULT_PLACEHOLDER
1112

1213
# Make available for import
@@ -40,6 +41,7 @@ class Radicli:
4041
extra_key: str
4142
commands: Dict[str, Command]
4243
subcommands: Dict[str, Dict[str, Command]]
44+
errors: ErrorHandlersType
4345
_subcommand_key: str
4446
_help_arg: str
4547

@@ -50,6 +52,7 @@ def __init__(
5052
help: str = "",
5153
version: Optional[str] = None,
5254
converters: ConvertersType = SimpleFrozenDict(),
55+
errors: Optional[ErrorHandlersType] = None,
5356
extra_key: str = "_extra",
5457
) -> None:
5558
"""Initialize the CLI and create the registry."""
@@ -61,6 +64,7 @@ def __init__(
6164
self.extra_key = extra_key
6265
self.commands = {}
6366
self.subcommands = {}
67+
self.errors = dict(errors) if errors is not None else {}
6468
self._subcommand_key = "__subcommand__" # should not conflict with arg name!
6569
self._help_arg = "--help"
6670

@@ -231,7 +235,17 @@ def run(self, args: Optional[List[str]] = None) -> None:
231235
)
232236
sub = values.pop(self._subcommand_key, None)
233237
func = subcommands[sub].func if sub else cmd.func
234-
func(**values)
238+
# Catch specific error types (and their subclasses), and invoke
239+
# their handler callback. Handlers can return an integer exit code,
240+
# which will be passed to sys.exit.
241+
errors_map = expand_error_subclasses(self.errors)
242+
try:
243+
func(**values)
244+
except tuple(errors_map.keys()) as e:
245+
handler = errors_map[e.__class__]
246+
err_code = handler(e)
247+
if err_code is not None:
248+
sys.exit(err_code)
235249

236250
def parse(
237251
self,

radicli/tests/test_cli.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import List, Iterator, Optional, Literal, TypeVar, Generic
1+
from typing import List, Iterator, Optional, Literal, TypeVar, Generic, Type
22
from enum import Enum
33
from dataclasses import dataclass
44
import pytest
@@ -679,3 +679,50 @@ def child(a: str):
679679
assert ran_parent
680680
cli.run(["", "child", "--a", "hello"])
681681
assert ran_child
682+
683+
684+
@pytest.mark.parametrize(
685+
"raise_error, handle_errors, handler_return, expect_handled, expect_exit",
686+
[
687+
(None, [KeyError], None, False, False),
688+
(KeyError, [KeyError], None, True, False),
689+
(KeyError, [KeyError], 1, True, True),
690+
(KeyError, [], 1, True, True),
691+
(KeyError, [ValueError], 1, True, True),
692+
],
693+
)
694+
def test_cli_errors(
695+
raise_error: Optional[Type[Exception]],
696+
handle_errors: List[Type[Exception]],
697+
handler_return: Optional[int],
698+
expect_handled: bool,
699+
expect_exit: bool,
700+
):
701+
handler_ran = False
702+
703+
def error_handler(e: Exception) -> Optional[int]:
704+
nonlocal handler_ran
705+
handler_ran = True
706+
return handler_return
707+
708+
cli = Radicli(errors={e: error_handler for e in handle_errors})
709+
710+
@cli.command("test")
711+
def test():
712+
nonlocal ran
713+
ran = True
714+
if raise_error is not None:
715+
raise raise_error
716+
717+
ran = False
718+
719+
if raise_error is not None and raise_error not in handle_errors:
720+
with pytest.raises(raise_error):
721+
cli.run(["", "test"])
722+
elif expect_exit:
723+
with pytest.raises(SystemExit):
724+
cli.run(["", "test"])
725+
else:
726+
cli.run(["", "test"])
727+
assert ran
728+
assert handler_ran is expect_handled

radicli/util.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
BASE_TYPES = [str, int, float, Path]
1818
ConverterType = Callable[[str], Any]
1919
ConvertersType = Dict[Union[Type, object], ConverterType]
20+
ErrorHandlerType = Callable[[Exception], Optional[int]]
21+
ErrorHandlersType = Dict[Type[Exception], ErrorHandlerType]
2022

2123

2224
class CliParserError(SystemExit):
@@ -246,6 +248,19 @@ def format_arg_help(text: Optional[str], max_width: int = 70) -> str:
246248
return (d.rsplit(".", 1)[0] if "." in d else d) + end
247249

248250

251+
def expand_error_subclasses(
252+
errors: Dict[Type[Exception], ErrorHandlerType]
253+
) -> Dict[Type[Exception], ErrorHandlerType]:
254+
"""Map subclasses of errors to their parent's handler."""
255+
output = {}
256+
for err, callback in errors.items():
257+
if hasattr(err, "__subclasses__"):
258+
for subclass in err.__subclasses__():
259+
output[subclass] = callback
260+
output[err] = callback
261+
return output
262+
263+
249264
def convert_existing_path(path_str: str) -> Path:
250265
path = Path(path_str)
251266
if not path.exists():

0 commit comments

Comments
 (0)