diff --git a/README.md b/README.md
index bb1ccf9..d07cf39 100644
--- a/README.md
+++ b/README.md
@@ -271,6 +271,7 @@ Typer uses Rich: and Typer will show nicely formatted errors automatically.
* colorama
: 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. wasabi
, blessings
.
diff --git a/docs/index.md b/docs/index.md
index bb1ccf9..d07cf39 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -271,6 +271,7 @@ Typer uses Rich: and Typer will show nicely formatted errors automatically.
* colorama
: 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. wasabi
, blessings
.
diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md
index 11fd559..8c4dd22 100644
--- a/docs/tutorial/index.md
+++ b/docs/tutorial/index.md
@@ -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
```
-...that also includes `colorama` and `shellingham`.
+...that also includes `colorama`, `shellingham`, and `rich`.
diff --git a/pyproject.toml b/pyproject.toml
index 224a4d7..808cb1a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]
diff --git a/tests/assets/type_error_no_rich.py b/tests/assets/type_error_no_rich.py
new file mode 100644
index 0000000..ffddd3b
--- /dev/null
+++ b/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)
diff --git a/tests/assets/type_error_normal_traceback.py b/tests/assets/type_error_normal_traceback.py
new file mode 100644
index 0000000..bcdc0ed
--- /dev/null
+++ b/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)()
diff --git a/tests/assets/type_error_rich.py b/tests/assets/type_error_rich.py
new file mode 100644
index 0000000..071c28c
--- /dev/null
+++ b/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)
diff --git a/tests/test_tracebacks.py b/tests/test_tracebacks.py
new file mode 100644
index 0000000..2f692ae
--- /dev/null
+++ b/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
+ )
diff --git a/typer/main.py b/typer/main.py
index b314172..1514f7d 100644
--- a/typer/main.py
+++ b/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
@@ -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: