Add pretty error tracebacks for user errors and support for Rich (#412)

This commit is contained in:
Sebastián Ramírez 2022-07-06 13:32:19 +02:00 committed by GitHub
parent 04898575a9
commit 252ed30936
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 191 additions and 5 deletions

View file

@ -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>.

View file

@ -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>.

View file

@ -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`.

View file

@ -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]

View 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)

View 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)()

View 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
View 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
)

View file

@ -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: