Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 9 commits into from Jul 6, 2022
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -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>.
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Expand Up @@ -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>.
Expand Down
4 changes: 2 additions & 2 deletions docs/tutorial/index.md
Expand Up @@ -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`.
6 changes: 4 additions & 2 deletions pyproject.toml
Expand Up @@ -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",
Expand All @@ -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]
Expand Down
12 changes: 12 additions & 0 deletions tests/assets/type_error_no_rich.py
@@ -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 changes: 22 additions & 0 deletions tests/assets/type_error_normal_traceback.py
@@ -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 changes: 9 additions & 0 deletions tests/assets/type_error_rich.py
@@ -0,0 +1,9 @@
import typer


def main(name: str = "morty"):
print(name + 3)


if __name__ == "__main__":
typer.run(main)
64 changes: 64 additions & 0 deletions tests/test_tracebacks.py
@@ -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
)
77 changes: 76 additions & 1 deletion typer/main.py
@@ -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

Expand Down Expand Up @@ -30,6 +35,64 @@
)
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()
Expand Down Expand Up @@ -211,7 +274,19 @@ def add_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:
Expand Down