diff --git a/news/382.deps.md b/news/382.deps.md new file mode 100644 index 00000000..1ea6af46 --- /dev/null +++ b/news/382.deps.md @@ -0,0 +1 @@ +Removed `crashtest` dependency and vendored part of it into `cleo` diff --git a/poetry.lock b/poetry.lock index a23d89e1..562904b3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "alabaster" @@ -279,17 +279,6 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] -[[package]] -name = "crashtest" -version = "0.4.1" -description = "Manage Python errors with ease" -optional = false -python-versions = ">=3.7,<4.0" -files = [ - {file = "crashtest-0.4.1-py3-none-any.whl", hash = "sha256:8d23eac5fa660409f57472e3851dab7ac18aba459a8d19cbbba86d3d5aecd2a5"}, - {file = "crashtest-0.4.1.tar.gz", hash = "sha256:80d7b1f316ebfbd429f648076d6275c877ba30ba48979de4191714a75266f0ce"}, -] - [[package]] name = "distlib" version = "0.3.7" @@ -515,6 +504,16 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -844,6 +843,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -851,8 +851,16 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -869,6 +877,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -876,6 +885,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -1356,4 +1366,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "99e188820ea9b593c65e9e260af8dfe70c19299938075726d30d1a3cc566c9fe" +content-hash = "e0ad446826807bc0c0e53172b352abcfe55a3063ce5ea56c57a86906bfefc79b" diff --git a/pyproject.toml b/pyproject.toml index 2e808325..5990cd09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.7" -crashtest = "^0.4.1" rapidfuzz = "^3.0.0" [tool.poetry.group.dev.dependencies] diff --git a/src/cleo/application.py b/src/cleo/application.py index ef8d1f64..3f356101 100644 --- a/src/cleo/application.py +++ b/src/cleo/application.py @@ -34,10 +34,6 @@ if TYPE_CHECKING: - from crashtest.solution_providers.solution_provider_repository import ( - SolutionProviderRepository, - ) - from cleo.commands.command import Command from cleo.events.event_dispatcher import EventDispatcher from cleo.io.inputs.input import Input @@ -78,8 +74,6 @@ def __init__(self, name: str = "console", version: str = "") -> None: self._command_loader: CommandLoader | None = None - self._solution_provider_repository: SolutionProviderRepository | None = None - @property def name(self) -> str: return self._name @@ -170,11 +164,6 @@ def catch_exceptions(self, catch_exceptions: bool = True) -> None: def is_single_command(self) -> bool: return self._single_command - def set_solution_provider_repository( - self, solution_provider_repository: SolutionProviderRepository - ) -> None: - self._solution_provider_repository = solution_provider_repository - def add(self, command: Command) -> Command | None: self._init() @@ -493,11 +482,9 @@ def create_io( return IO(input, output, error_output) def render_error(self, error: Exception, io: IO) -> None: - from cleo.ui.exception_trace import ExceptionTrace + from cleo.ui.exception_trace.component import ExceptionTrace - trace = ExceptionTrace( - error, solution_provider_repository=self._solution_provider_repository - ) + trace = ExceptionTrace(error) simple = not io.is_verbose() or isinstance(error, CleoUserError) trace.render(io.error_output, simple) diff --git a/src/cleo/ui/exception_trace/__init__.py b/src/cleo/ui/exception_trace/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cleo/ui/exception_trace.py b/src/cleo/ui/exception_trace/component.py similarity index 87% rename from src/cleo/ui/exception_trace.py rename to src/cleo/ui/exception_trace/component.py index 1e65c5e2..57aec132 100644 --- a/src/cleo/ui/exception_trace.py +++ b/src/cleo/ui/exception_trace/component.py @@ -10,22 +10,18 @@ import sys import tokenize +from pathlib import Path from typing import TYPE_CHECKING from typing import ClassVar -from crashtest.frame_collection import FrameCollection - from cleo.formatters.formatter import Formatter +from cleo.ui.exception_trace.frame_collection import FrameCollection if TYPE_CHECKING: - from crashtest.frame import Frame - from crashtest.solution_providers.solution_provider_repository import ( - SolutionProviderRepository, - ) - from cleo.io.io import IO from cleo.io.outputs.output import Output + from cleo.ui.exception_trace.frame import Frame class Highlighter: @@ -231,10 +227,8 @@ class ExceptionTrace: def __init__( self, exception: Exception, - solution_provider_repository: SolutionProviderRepository | None = None, ) -> None: self._exception = exception - self._solution_provider_repository = solution_provider_repository self._exc_info = sys.exc_info() self._ignore: str | None = None @@ -252,10 +246,8 @@ def render(self, io: IO | Output, simple: bool = False) -> None: else: self._render_exception(io, self._exception) - self._render_solution(io, self._exception) - def _render_exception(self, io: IO | Output, exception: BaseException) -> None: - from crashtest.inspector import Inspector + from cleo.ui.exception_trace.inspector import Inspector inspector = Inspector(exception) if not inspector.frames: @@ -297,31 +289,6 @@ def _render_snippet(self, io: IO | Output, frame: Frame) -> None: for code_line in code_lines: self._render_line(io, code_line, indent=4) - def _render_solution(self, io: IO | Output, exception: Exception) -> None: - if self._solution_provider_repository is None: - return - - solutions = self._solution_provider_repository.get_solutions_for_exception( - exception - ) - symbol = "•" if io.supports_utf8() else "*" - - for solution in solutions: - title = solution.solution_title - description = solution.solution_description - links = solution.documentation_links - - description = description.replace("\n", "\n ").strip(" ") - - joined_links = ",".join(f"\n {link}" for link in links) - self._render_line( - io, - f"{symbol} " - f"{title.rstrip('.')}:" - f" {description}{joined_links}", - True, - ) - def _render_trace(self, io: IO | Output, frames: FrameCollection) -> None: stack_frames = FrameCollection() for frame in frames: @@ -341,7 +308,7 @@ def _render_trace(self, io: IO | Output, frames: FrameCollection) -> None: frame_collections = stack_frames.compact() i = remaining_frames_length for collection in frame_collections: - if collection.is_repeated(): + if collection.is_repeated: if len(collection) > 1: frames_message = f"{len(collection)} frames" else: @@ -359,7 +326,7 @@ def _render_trace(self, io: IO | Output, frames: FrameCollection) -> None: for frame in collection: relative_file_path = self._get_relative_file_path(frame.filename) - relative_file_path_parts = relative_file_path.split(os.path.sep) + relative_file_path_parts = relative_file_path.split(os.sep) relative_file_path = ( f"{Formatter.escape(os.sep)}".join( relative_file_path_parts[:-1] @@ -411,22 +378,21 @@ def _render_trace(self, io: IO | Output, frames: FrameCollection) -> None: i -= 1 + @staticmethod def _render_line( - self, io: IO | Output, line: str, new_line: bool = False, indent: int = 2 + io: IO | Output, line: str, new_line: bool = False, indent: int = 2 ) -> None: if new_line: io.write_line("") io.write_line(f"{indent * ' '}{line}") - def _get_relative_file_path(self, filepath: str) -> str: - cwd = os.getcwd() - - if cwd: - filepath = filepath.replace(cwd + os.path.sep, "") + @staticmethod + def _get_relative_file_path(filepath: str) -> str: + if cwd := Path.cwd(): + filepath = filepath.replace(f"{cwd}{os.sep}", "") - home = os.path.expanduser("~") - if home: - filepath = filepath.replace(home + os.path.sep, "~" + os.path.sep) + if home := Path("~").expanduser(): + filepath = filepath.replace(f"{home}{os.sep}", f"~{os.sep}") return filepath diff --git a/src/cleo/ui/exception_trace/frame.py b/src/cleo/ui/exception_trace/frame.py new file mode 100644 index 00000000..610f2cdc --- /dev/null +++ b/src/cleo/ui/exception_trace/frame.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import operator + +from functools import reduce +from pathlib import Path +from typing import TYPE_CHECKING +from typing import ClassVar + + +if TYPE_CHECKING: + import inspect + + from types import FrameType + + +class Frame: + _content_cache: ClassVar[dict[str, str]] = {} + + def __init__(self, frame_info: inspect.FrameInfo) -> None: + self._frame = frame_info.frame + self._frame_info = frame_info + self._lineno = frame_info.lineno + self._filename = frame_info.filename + self._function = frame_info.function + self._lines = None + self._file_content: str | None = None + + @property + def frame(self) -> FrameType: + return self._frame + + @property + def lineno(self) -> int: + return self._lineno + + @property + def filename(self) -> str: + return self._filename + + @property + def function(self) -> str: + return self._function + + @property + def line(self) -> str: + if not self._frame_info.code_context: + return "" + + return self._frame_info.code_context[0] + + @property + def _key(self) -> tuple[str, str, int]: + return self._filename, self._function, self._lineno + + @property + def file_content(self) -> str: + if self._file_content is not None: + return self._file_content + if not self._filename: + self._file_content = "" + return "" + if self._filename not in type(self)._content_cache: + try: + file_content = Path(self._filename).read_text() + except OSError: + file_content = "" + type(self)._content_cache[self._filename] = file_content + self._file_content = type(self)._content_cache[self._filename] + return self._file_content + + def __hash__(self) -> int: + return reduce(operator.xor, map(hash, self._key)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Frame): + return NotImplemented + return self._key == other._key + + def __repr__(self) -> str: + return f"" diff --git a/src/cleo/ui/exception_trace/frame_collection.py b/src/cleo/ui/exception_trace/frame_collection.py new file mode 100644 index 00000000..5f30eba2 --- /dev/null +++ b/src/cleo/ui/exception_trace/frame_collection.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import List + +from cleo.ui.exception_trace.frame import Frame + + +class FrameCollection(List[Frame]): + def __init__(self, frames: list[Frame] | None = None, count: int = 0) -> None: + if frames is None: + frames = [] + + super().__init__(frames) + + self._count = count + + @property + def repetitions(self) -> int: + return self._count - 1 + + @property + def is_repeated(self) -> bool: + return self._count > 1 + + def increment_count(self, increment: int = 1) -> FrameCollection: + self._count += increment + + return self + + def compact(self) -> list[FrameCollection]: + """ + Compacts the frames to deduplicate recursive calls. + """ + collections = [] + current_collection = FrameCollection() + + i = 0 + while i < len(self) - 1: + frame = self[i] + if frame in self[i + 1 :]: + duplicate_indices = [] + for sub_index, sub_frame in enumerate(self[i + 1 :]): + if frame == sub_frame: + duplicate_indices.append(sub_index + i + 1) + + found_duplicate = False + for duplicate_index in duplicate_indices: + collection = FrameCollection(self[i:duplicate_index]) + if collection == current_collection: + current_collection.increment_count() + i = duplicate_index + found_duplicate = True + break + + if found_duplicate: + continue + + collections.append(current_collection) + current_collection = FrameCollection(self[i : duplicate_indices[0]]) + + i = duplicate_indices[0] + + continue + + if current_collection.is_repeated: + collections.append(current_collection) + current_collection = FrameCollection() + + current_collection.append(frame) + i += 1 + + collections.append(current_collection) + + return collections diff --git a/src/cleo/ui/exception_trace/inspector.py b/src/cleo/ui/exception_trace/inspector.py new file mode 100644 index 00000000..0cad63e9 --- /dev/null +++ b/src/cleo/ui/exception_trace/inspector.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import inspect + +from cleo.ui.exception_trace.frame import Frame +from cleo.ui.exception_trace.frame_collection import FrameCollection + + +class Inspector: + def __init__(self, exception: BaseException) -> None: + self._exception = exception + self._frames: FrameCollection | None = None + self._outer_frames = None + self._inner_frames = None + self._previous_exception = exception.__context__ + + @property + def exception(self) -> BaseException: + return self._exception + + @property + def exception_name(self) -> str: + return type(self._exception).__name__ + + @property + def exception_message(self) -> str: + return str(self._exception) + + @property + def frames(self) -> FrameCollection: + if self._frames is not None: + return self._frames + + self._frames = FrameCollection() + + tb = self._exception.__traceback__ + + while tb: + frame_info = inspect.getframeinfo(tb) + self._frames.append(Frame(inspect.FrameInfo(tb.tb_frame, *frame_info))) + tb = tb.tb_next + + return self._frames + + @property + def previous_exception(self) -> BaseException | None: + return self._previous_exception + + def has_previous_exception(self) -> bool: + return self._previous_exception is not None diff --git a/tests/fixtures/exceptions/solution.py b/tests/fixtures/exceptions/solution.py deleted file mode 100644 index 14dcefc1..00000000 --- a/tests/fixtures/exceptions/solution.py +++ /dev/null @@ -1,16 +0,0 @@ -from crashtest.contracts.base_solution import BaseSolution -from crashtest.contracts.provides_solution import ProvidesSolution - - -class CustomError(ProvidesSolution, Exception): - @property - def solution(self) -> BaseSolution: - solution = BaseSolution("Solution Title.", "Solution Description") - solution.documentation_links.append("https://example.com") - solution.documentation_links.append("https://example2.com") - - return solution - - -def call() -> None: - raise CustomError("Error with solution") diff --git a/tests/ui/exception_trace/__init__.py b/tests/ui/exception_trace/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ui/exception_trace/helpers.py b/tests/ui/exception_trace/helpers.py new file mode 100644 index 00000000..3fe8b570 --- /dev/null +++ b/tests/ui/exception_trace/helpers.py @@ -0,0 +1,22 @@ +from __future__ import annotations + + +def simple_exception() -> None: + raise ValueError("Simple Exception") + + +def nested_exception() -> None: + try: + simple_exception() + except ValueError: + raise RuntimeError("Nested Exception") # noqa: B904 + + +def recursive_exception() -> None: + def inner() -> None: + outer() + + def outer() -> None: + inner() + + inner() diff --git a/tests/ui/exception_trace/test_frame.py b/tests/ui/exception_trace/test_frame.py new file mode 100644 index 00000000..6932cb30 --- /dev/null +++ b/tests/ui/exception_trace/test_frame.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import inspect + +from cleo.ui.exception_trace.frame import Frame +from tests.ui.exception_trace.helpers import nested_exception +from tests.ui.exception_trace.helpers import simple_exception + + +def test_frame() -> None: + try: + simple_exception() + except ValueError as e: + assert e.__traceback__ is not None + frame_info = inspect.getinnerframes(e.__traceback__)[0] + frame = Frame(frame_info) + same_frame = Frame(frame_info) + assert frame_info.frame == frame.frame + + assert frame.lineno == 12 + assert frame.filename == __file__ + assert frame.function == "test_frame" + assert frame.line == " simple_exception()\n" + + with open(__file__) as f: + assert f.read() == frame.file_content + + assert repr(frame) == f"" + + try: + nested_exception() + except Exception as e: + assert e.__traceback__ is not None + frame_info = inspect.getinnerframes(e.__traceback__)[0] + other_frame = Frame(frame_info) + + assert same_frame == frame + assert other_frame != frame + assert hash(same_frame) == hash(frame) + assert hash(other_frame) != hash(frame) + + +def test_frame_with_no_context_should_return_empty_line() -> None: + frame = Frame( + inspect.FrameInfo(None, "filename.py", 123, "function", None, 3) # type: ignore[arg-type] + ) + + assert frame.line == "" diff --git a/tests/ui/exception_trace/test_inspector.py b/tests/ui/exception_trace/test_inspector.py new file mode 100644 index 00000000..ac330f03 --- /dev/null +++ b/tests/ui/exception_trace/test_inspector.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from cleo.ui.exception_trace.inspector import Inspector +from tests.ui.exception_trace.helpers import nested_exception +from tests.ui.exception_trace.helpers import recursive_exception +from tests.ui.exception_trace.helpers import simple_exception + + +def test_inspector_with_simple_exception() -> None: + try: + simple_exception() + except ValueError as e: + inspector = Inspector(e) + + assert inspector.exception == e + assert not inspector.has_previous_exception() + assert inspector.previous_exception is None + assert inspector.exception_name == "ValueError" + assert inspector.exception_message == "Simple Exception" + assert len(inspector.frames) > 0 + + +def test_inspector_with_nested_exception() -> None: + try: + nested_exception() + except RuntimeError as e: + inspector = Inspector(e) + + assert inspector.exception == e + assert inspector.has_previous_exception() + assert inspector.previous_exception is not None + assert inspector.exception_name == "RuntimeError" + assert inspector.exception_message == "Nested Exception" + assert len(inspector.frames) > 0 + assert len(inspector.frames.compact()) == 1 + + +def test_inspector_with_recursive_exception() -> None: + try: + recursive_exception() + except RuntimeError as e: + inspector = Inspector(e) + + assert inspector.exception == e + assert not inspector.has_previous_exception() + assert inspector.previous_exception is None + assert inspector.exception_name == "RecursionError" + assert inspector.exception_message == "maximum recursion depth exceeded" + assert len(inspector.frames) > 0 + assert len(inspector.frames) > len(inspector.frames.compact()) diff --git a/tests/ui/test_exception_trace.py b/tests/ui/test_exception_trace.py index 2149d80b..6c8cfc69 100644 --- a/tests/ui/test_exception_trace.py +++ b/tests/ui/test_exception_trace.py @@ -8,12 +8,11 @@ from cleo.io.buffered_io import BufferedIO from cleo.io.outputs.output import Verbosity -from cleo.ui.exception_trace import ExceptionTrace +from cleo.ui.exception_trace.component import ExceptionTrace from tests.fixtures.exceptions import nested1 from tests.fixtures.exceptions import nested2 from tests.fixtures.exceptions import recursion from tests.fixtures.exceptions import simple -from tests.fixtures.exceptions import solution def test_render_better_error_message() -> None: @@ -51,7 +50,7 @@ def test_render_debug_better_error_message() -> None: trace.render(io) - lineno = 48 + lineno = 47 expected = f""" Stack trace: @@ -85,7 +84,7 @@ def test_render_debug_better_error_message_recursion_error() -> None: except RecursionError as e: trace = ExceptionTrace(e) - lineno = 84 + lineno = 83 trace.render(io) expected = rf"""^ @@ -132,7 +131,7 @@ def test_render_very_verbose_better_error_message() -> None: expected = f""" Stack trace: - 1 {trace._get_relative_file_path(__file__)}:126 in \ + 1 {trace._get_relative_file_path(__file__)}:125 in \ test_render_very_verbose_better_error_message simple.simple_exception() @@ -184,7 +183,7 @@ def test_render_can_ignore_given_files() -> None: trace.ignore_files_in(rf"^{re.escape(nested1.__file__)}$") trace.render(io) - lineno = 181 + lineno = 180 expected = f""" Stack trace: @@ -222,7 +221,7 @@ def test_render_shows_ignored_files_if_in_debug_mode() -> None: trace.ignore_files_in(rf"^{re.escape(nested1.__file__)}$") trace.render(io) - lineno = 219 + lineno = 218 expected = f""" Stack trace: @@ -269,90 +268,40 @@ def test_render_shows_ignored_files_if_in_debug_mode() -> None: assert io.fetch_output() == expected -def test_render_supports_solutions() -> None: - from crashtest.solution_providers.solution_provider_repository import ( - SolutionProviderRepository, - ) - - io = BufferedIO() - - with pytest.raises(solution.CustomError) as e: - solution.call() - - trace = ExceptionTrace( - e.value, solution_provider_repository=SolutionProviderRepository() - ) - - trace.render(io) - - expected = f""" - CustomError - - Error with solution - - at {trace._get_relative_file_path(solution.__file__)}:16 in call - 12│ return solution - 13│ - 14│ - 15│ def call() -> None: - → 16│ raise CustomError("Error with solution") - 17│ - - • Solution Title: Solution Description - https://example.com, - https://example2.com -""" - - assert io.fetch_output() == expected - - def test_render_falls_back_on_ascii_symbols() -> None: - from crashtest.solution_providers.solution_provider_repository import ( - SolutionProviderRepository, - ) - io = BufferedIO(supports_utf8=False) - with pytest.raises(solution.CustomError) as e: - solution.call() + with pytest.raises(Exception) as e: + simple.simple_exception() - trace = ExceptionTrace( - e.value, solution_provider_repository=SolutionProviderRepository() - ) + trace = ExceptionTrace(e.value) trace.render(io) expected = f""" - CustomError - - Error with solution + Exception - at {trace._get_relative_file_path(solution.__file__)}:16 in call - 12| return solution - 13| - 14| - 15| def call() -> None: - > 16| raise CustomError("Error with solution") - 17| + Failed - * Solution Title: Solution Description - https://example.com, - https://example2.com + at {trace._get_relative_file_path(simple.__file__)}:2 in simple_exception + 1| def simple_exception() -> None: + > 2| raise Exception("Failed") + 3| """ assert io.fetch_output() == expected def test_empty_source_file_do_not_break_highlighter() -> None: - from cleo.ui.exception_trace import Highlighter + from cleo.ui.exception_trace.component import Highlighter highlighter = Highlighter() highlighter.highlighted_lines("") -def test_doctrings_are_corrrectly_rendered() -> None: +def test_docstrings_are_correctly_rendered() -> None: from cleo.formatters.formatter import Formatter - from cleo.ui.exception_trace import Highlighter + from cleo.ui.exception_trace.component import Highlighter source = ''' def test(): @@ -386,32 +335,6 @@ def test_simple_render() -> None: assert io.fetch_output() == expected -def test_simple_render_supports_solutions() -> None: - from crashtest.solution_providers.solution_provider_repository import ( - SolutionProviderRepository, - ) - - io = BufferedIO() - - with pytest.raises(solution.CustomError) as e: - solution.call() - - trace = ExceptionTrace( - e.value, solution_provider_repository=SolutionProviderRepository() - ) - - trace.render(io, simple=True) - - expected = """ -Error with solution - - • Solution Title: Solution Description - https://example.com, - https://example2.com -""" - assert io.fetch_output() == expected - - def test_simple_render_aborts_if_no_message() -> None: io = BufferedIO() @@ -421,7 +344,7 @@ def test_simple_render_aborts_if_no_message() -> None: trace = ExceptionTrace(e.value) trace.render(io, simple=True) - lineno = 419 + lineno = 342 expected = f""" AssertionError