✨ Add pretty error tracebacks for user errors and support for Rich (#412)
This commit is contained in:
parent
04898575a9
commit
252ed30936
9 changed files with 191 additions and 5 deletions
|
@ -271,6 +271,7 @@ Typer uses <a href="https://click.palletsprojects.com/" class="external-link" ta
|
|||
|
||||
But you can also install extras:
|
||||
|
||||
* <a href="https://rich.readthedocs.io/en/stable/index.html" class="external-link" target="_blank">Rich</a>: and Typer will show nicely formatted errors automatically.
|
||||
* <a href="https://pypi.org/project/colorama/" class="external-link" target="_blank"><code>colorama</code></a>: and Click will automatically use it to make sure your terminal's colors always work correctly, even in Windows.
|
||||
* Then you can use any tool you want to output your terminal's colors in all the systems, including the integrated `typer.style()` and `typer.secho()` (provided by Click).
|
||||
* Or any other tool, e.g. <a href="https://pypi.org/project/wasabi/" class="external-link" target="_blank"><code>wasabi</code></a>, <a href="https://github.com/erikrose/blessings" class="external-link" target="_blank"><code>blessings</code></a>.
|
||||
|
|
|
@ -271,6 +271,7 @@ Typer uses <a href="https://click.palletsprojects.com/" class="external-link" ta
|
|||
|
||||
But you can also install extras:
|
||||
|
||||
* <a href="https://rich.readthedocs.io/en/stable/index.html" class="external-link" target="_blank">Rich</a>: and Typer will show nicely formatted errors automatically.
|
||||
* <a href="https://pypi.org/project/colorama/" class="external-link" target="_blank"><code>colorama</code></a>: and Click will automatically use it to make sure your terminal's colors always work correctly, even in Windows.
|
||||
* Then you can use any tool you want to output your terminal's colors in all the systems, including the integrated `typer.style()` and `typer.secho()` (provided by Click).
|
||||
* Or any other tool, e.g. <a href="https://pypi.org/project/wasabi/" class="external-link" target="_blank"><code>wasabi</code></a>, <a href="https://github.com/erikrose/blessings" class="external-link" target="_blank"><code>blessings</code></a>.
|
||||
|
|
|
@ -70,9 +70,9 @@ For the tutorial, you might want to install it with all the optional dependencie
|
|||
```console
|
||||
$ pip install typer[all]
|
||||
---> 100%
|
||||
Successfully installed typer click colorama shellingham
|
||||
Successfully installed typer click colorama shellingham rich
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
...that also includes `colorama` and `shellingham`.
|
||||
...that also includes `colorama`, `shellingham`, and `rich`.
|
||||
|
|
|
@ -45,7 +45,8 @@ test = [
|
|||
"pytest-sugar >=0.9.4,<0.10.0",
|
||||
"mypy ==0.910",
|
||||
"black >=22.3.0,<23.0.0",
|
||||
"isort >=5.0.6,<6.0.0"
|
||||
"isort >=5.0.6,<6.0.0",
|
||||
"rich >=10.11.0,<13.0.0",
|
||||
]
|
||||
doc = [
|
||||
"mkdocs >=1.1.2,<2.0.0",
|
||||
|
@ -59,7 +60,8 @@ dev = [
|
|||
]
|
||||
all = [
|
||||
"colorama >=0.4.3,<0.5.0",
|
||||
"shellingham >=1.3.0,<2.0.0"
|
||||
"shellingham >=1.3.0,<2.0.0",
|
||||
"rich >=10.11.0,<13.0.0",
|
||||
]
|
||||
|
||||
[tool.isort]
|
||||
|
|
12
tests/assets/type_error_no_rich.py
Normal file
12
tests/assets/type_error_no_rich.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
import typer
|
||||
import typer.main
|
||||
|
||||
typer.main.rich = None
|
||||
|
||||
|
||||
def main(name: str = "morty"):
|
||||
print(name + 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
typer.run(main)
|
22
tests/assets/type_error_normal_traceback.py
Normal file
22
tests/assets/type_error_normal_traceback.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
import typer
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(name: str = "morty"):
|
||||
print(name)
|
||||
|
||||
|
||||
broken_app = typer.Typer()
|
||||
|
||||
|
||||
@broken_app.command()
|
||||
def broken(name: str = "morty"):
|
||||
print(name + 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app(standalone_mode=False)
|
||||
|
||||
typer.main.get_command(broken_app)()
|
9
tests/assets/type_error_rich.py
Normal file
9
tests/assets/type_error_rich.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
import typer
|
||||
|
||||
|
||||
def main(name: str = "morty"):
|
||||
print(name + 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
typer.run(main)
|
64
tests/test_tracebacks.py
Normal file
64
tests/test_tracebacks.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_traceback_rich():
|
||||
file_path = Path(__file__).parent / "assets/type_error_rich.py"
|
||||
result = subprocess.run(
|
||||
["coverage", "run", str(file_path)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert "return get_command(self)(*args, **kwargs)" not in result.stderr
|
||||
|
||||
assert "typer.run(main)" in result.stderr
|
||||
assert "print(name + 3)" in result.stderr
|
||||
|
||||
# TODO: when deprecating Python 3.6, remove second option
|
||||
assert (
|
||||
'TypeError: can only concatenate str (not "int") to str' in result.stderr
|
||||
or "TypeError: must be str, not int" in result.stderr
|
||||
)
|
||||
assert "name = 'morty'" in result.stderr
|
||||
|
||||
|
||||
def test_traceback_no_rich():
|
||||
file_path = Path(__file__).parent / "assets/type_error_no_rich.py"
|
||||
result = subprocess.run(
|
||||
["coverage", "run", str(file_path)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert "return get_command(self)(*args, **kwargs)" not in result.stderr
|
||||
|
||||
assert "typer.run(main)" in result.stderr
|
||||
assert "print(name + 3)" in result.stderr
|
||||
# TODO: when deprecating Python 3.6, remove second option
|
||||
assert (
|
||||
'TypeError: can only concatenate str (not "int") to str' in result.stderr
|
||||
or "TypeError: must be str, not int" in result.stderr
|
||||
)
|
||||
|
||||
|
||||
def test_unmodified_traceback():
|
||||
file_path = Path(__file__).parent / "assets/type_error_normal_traceback.py"
|
||||
result = subprocess.run(
|
||||
["coverage", "run", str(file_path)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert "morty" in result.stdout, "the call to the first app should work normally"
|
||||
assert "return callback(**use_params)" in result.stderr, (
|
||||
"calling outside of Typer should show the normal traceback, "
|
||||
"even after the hook is installed"
|
||||
)
|
||||
assert "typer.main.get_command(broken_app)()" in result.stderr
|
||||
assert "print(name + 3)" in result.stderr
|
||||
# TODO: when deprecating Python 3.6, remove second option
|
||||
assert (
|
||||
'TypeError: can only concatenate str (not "int") to str' in result.stderr
|
||||
or "TypeError: must be str, not int" in result.stderr
|
||||
)
|
|
@ -1,8 +1,13 @@
|
|||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from functools import update_wrapper
|
||||
from pathlib import Path
|
||||
from traceback import FrameSummary, StackSummary
|
||||
from types import TracebackType
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union
|
||||
from uuid import UUID
|
||||
|
||||
|
@ -30,6 +35,64 @@ from .models import (
|
|||
)
|
||||
from .utils import get_params_from_function
|
||||
|
||||
try:
|
||||
import rich
|
||||
from rich.console import Console
|
||||
from rich.traceback import Traceback
|
||||
|
||||
console_stderr = Console(stderr=True)
|
||||
|
||||
except ImportError: # pragma: nocover
|
||||
rich = None # type: ignore
|
||||
|
||||
_original_except_hook = sys.excepthook
|
||||
_typer_developer_exception_attr_name = "__typer_developer_exception__"
|
||||
|
||||
|
||||
def except_hook(
|
||||
exc_type: Type[BaseException], exc_value: BaseException, tb: TracebackType
|
||||
) -> None:
|
||||
if not getattr(exc_value, _typer_developer_exception_attr_name, None):
|
||||
_original_except_hook(exc_type, exc_value, tb)
|
||||
return
|
||||
typer_path = os.path.dirname(__file__)
|
||||
click_path = os.path.dirname(click.__file__)
|
||||
supress_internal_dir_names = [typer_path, click_path]
|
||||
exc = exc_value
|
||||
if rich:
|
||||
rich_tb = Traceback.from_exception(
|
||||
type(exc),
|
||||
exc,
|
||||
exc.__traceback__,
|
||||
show_locals=True,
|
||||
suppress=supress_internal_dir_names,
|
||||
)
|
||||
console_stderr.print(rich_tb)
|
||||
return
|
||||
tb_exc = traceback.TracebackException.from_exception(exc)
|
||||
stack: List[FrameSummary] = []
|
||||
for frame in tb_exc.stack:
|
||||
if any(
|
||||
[frame.filename.startswith(path) for path in supress_internal_dir_names]
|
||||
):
|
||||
# Hide the line for internal libraries, Typer and Click
|
||||
stack.append(
|
||||
traceback.FrameSummary(
|
||||
filename=frame.filename,
|
||||
lineno=frame.lineno,
|
||||
name=frame.name,
|
||||
line="",
|
||||
)
|
||||
)
|
||||
else:
|
||||
stack.append(frame)
|
||||
# Type ignore ref: https://github.com/python/typeshed/pull/8244
|
||||
final_stack_summary = StackSummary.from_list(stack) # type: ignore
|
||||
tb_exc.stack = final_stack_summary
|
||||
for line in tb_exc.format():
|
||||
print(line, file=sys.stderr)
|
||||
return
|
||||
|
||||
|
||||
def get_install_completion_arguments() -> Tuple[click.Parameter, click.Parameter]:
|
||||
install_param, show_param = get_completion_inspect_parameters()
|
||||
|
@ -211,7 +274,19 @@ class Typer:
|
|||
)
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return get_command(self)(*args, **kwargs)
|
||||
if sys.excepthook != except_hook:
|
||||
sys.excepthook = except_hook
|
||||
try:
|
||||
return get_command(self)(*args, **kwargs)
|
||||
except Exception as e:
|
||||
# Set a custom attribute to tell the hook to show nice exceptions for user
|
||||
# code. An alternative/first implementation was a custom exception with
|
||||
# raise custom_exc from e
|
||||
# but that means the last error shown is the custom exception, not the
|
||||
# actual error. This trick improves developer experience by showing the
|
||||
# actual error last.
|
||||
setattr(e, _typer_developer_exception_attr_name, True)
|
||||
raise e
|
||||
|
||||
|
||||
def get_group(typer_instance: Typer) -> click.Command:
|
||||
|
|
Loading…
Reference in a new issue