diff --git a/news/382.deps.md b/news/382.deps.md new file mode 100644 index 00000000..cbbfe0af --- /dev/null +++ b/news/382.deps.md @@ -0,0 +1 @@ +Removed `crashtest` dependency and vendored part of it into `cleo` \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index a9c50bcd..967b3df0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -242,17 +242,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" @@ -1146,4 +1135,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "bf932ada0985bb687a328c6313c75087c93fc8b1d4bac8c03c378524301e214b" +content-hash = "d62b54e19368d22edfff1837af4297243e1f364c5b41cdec8c95419a12d32cf5" diff --git a/pyproject.toml b/pyproject.toml index b5df5fa7..9f9dd3e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.8" -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