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: