From 92c3939830b445604bbb26feac3f88e60e568f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Wed, 6 Jul 2022 11:41:03 +0200 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9C=A8=20Add=20pretty=20tracebacks=20for?= =?UTF-8?q?=20user=20errors=20and=20support=20for=20Rich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/main.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/typer/main.py b/typer/main.py index b31417206..de2137bf9 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,63 @@ ) 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: + rich = None + +_original_except_hook = sys.excepthook + + +def except_hook( + exc_type: Type[BaseException], exc_value: BaseException, tb: TracebackType +) -> None: + if not getattr(exc_value, "__typer_developer_exception__", 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 +273,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 attirbute 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__", True) + raise e def get_group(typer_instance: Typer) -> click.Command: From 7de5c4b2dc09136d4527cc74dbaa9e7a1515e993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Wed, 6 Jul 2022 12:01:10 +0200 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=8E=A8=20Tweak=20type=20annotations?= =?UTF-8?q?=20for=20optionally=20importing=20rich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/typer/main.py b/typer/main.py index de2137bf9..12edd1b84 100644 --- a/typer/main.py +++ b/typer/main.py @@ -42,8 +42,8 @@ console_stderr = Console(stderr=True) -except ImportError: - rich = None +except ImportError: # pragma: nocover + rich = None # type: ignore _original_except_hook = sys.excepthook From d1308bc0b7ee3ff58c1bd595f954a2759fcd27c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Wed, 6 Jul 2022 12:54:43 +0200 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9C=85=20Add=20tests=20for=20new=20error?= =?UTF-8?q?=20traceback=20display=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/assets/type_error_no_rich.py | 12 +++++ tests/assets/type_error_normal_traceback.py | 22 +++++++++ tests/assets/type_error_rich.py | 9 ++++ tests/test_tracebacks.py | 51 +++++++++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 tests/assets/type_error_no_rich.py create mode 100644 tests/assets/type_error_normal_traceback.py create mode 100644 tests/assets/type_error_rich.py create mode 100644 tests/test_tracebacks.py diff --git a/tests/assets/type_error_no_rich.py b/tests/assets/type_error_no_rich.py new file mode 100644 index 000000000..ffddd3b54 --- /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 000000000..bcdc0edbe --- /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 000000000..071c28c3a --- /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 000000000..69de280d1 --- /dev/null +++ b/tests/test_tracebacks.py @@ -0,0 +1,51 @@ +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 + assert 'TypeError: can only concatenate str (not "int") to str' 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 + assert 'TypeError: can only concatenate str (not "int") to str' 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 + assert 'TypeError: can only concatenate str (not "int") to str' in result.stderr From 5bf1a8c634e84ba580c65fbd8384aafd3e44a717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Wed, 6 Jul 2022 12:55:39 +0200 Subject: [PATCH 4/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20magic=20exc?= =?UTF-8?q?eption=20attribute=20name=20to=20avoid=20duplication=20and=20fu?= =?UTF-8?q?ture=20code=20sync=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/typer/main.py b/typer/main.py index 12edd1b84..d4da750c6 100644 --- a/typer/main.py +++ b/typer/main.py @@ -46,12 +46,13 @@ 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__", 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__) @@ -284,7 +285,7 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: # 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__", True) + setattr(e, _typer_developer_exception_attr_name, True) raise e From bf746f94439c6b41df2a4892aa3d1d638648d9b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Wed, 6 Jul 2022 13:08:32 +0200 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9E=95=20Add=20Rich=20as=20an=20optional?= =?UTF-8?q?=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 224a4d790..539b32b0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,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] From fe4bbf2e8891b75b7523ce27868a980dd73ba8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Wed, 6 Jul 2022 13:09:27 +0200 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=93=9D=20Update=20docs=20about=20opti?= =?UTF-8?q?onal=20dependencies=20to=20include=20Rich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/index.md | 1 + docs/tutorial/index.md | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bb1ccf96f..d07cf3939 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 bb1ccf96f..d07cf3939 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 11fd55943..8c4dd22e5 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`. From 57eb8e0661ac7e8e5d5f510a8ee721d64d7fcc59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Wed, 6 Jul 2022 13:11:12 +0200 Subject: [PATCH 7/9] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Fix=20typo=20in=20comm?= =?UTF-8?q?ent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typer/main.py b/typer/main.py index d4da750c6..1514f7d0f 100644 --- a/typer/main.py +++ b/typer/main.py @@ -279,7 +279,7 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: try: return get_command(self)(*args, **kwargs) except Exception as e: - # Set a custom attirbute to tell the hook to show nice exceptions for user + # 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 From ed8094e197d9733c0c5a7aa9debd3d886ad01abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Wed, 6 Jul 2022 13:14:14 +0200 Subject: [PATCH 8/9] =?UTF-8?q?=E2=9E=95=20Add=20Rich=20to=20test=20depend?= =?UTF-8?q?encies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 539b32b0f..808cb1ad6 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", From 4808dd76576f3fe0ff5a9702ac5f80a94703d117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Wed, 6 Jul 2022 13:24:17 +0200 Subject: [PATCH 9/9] =?UTF-8?q?=E2=9C=85=20Update=20tests=20to=20support?= =?UTF-8?q?=20Python=203.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_tracebacks.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/test_tracebacks.py b/tests/test_tracebacks.py index 69de280d1..2f692aefa 100644 --- a/tests/test_tracebacks.py +++ b/tests/test_tracebacks.py @@ -14,7 +14,12 @@ def test_traceback_rich(): assert "typer.run(main)" in result.stderr assert "print(name + 3)" in result.stderr - assert 'TypeError: can only concatenate str (not "int") to str' 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 @@ -30,7 +35,11 @@ def test_traceback_no_rich(): assert "typer.run(main)" in result.stderr assert "print(name + 3)" in result.stderr - assert 'TypeError: can only concatenate str (not "int") to str' 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(): @@ -48,4 +57,8 @@ def test_unmodified_traceback(): ) assert "typer.main.get_command(broken_app)()" in result.stderr assert "print(name + 3)" in result.stderr - assert 'TypeError: can only concatenate str (not "int") to str' 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 + )