From 4fd6f0588e1fab415ce75a1d55a282795e2cd2b2 Mon Sep 17 00:00:00 2001 From: Dmitry Volodin Date: Mon, 18 Apr 2022 08:50:13 +0200 Subject: [PATCH] ErrorInfoMiddleware --- docs/changes.md | 8 + src/gufo/err/__init__.py | 2 +- src/gufo/err/codec.py | 243 +++++++++++++++++++++++ src/gufo/err/compressor.py | 121 ++++++++++++ src/gufo/err/err.py | 43 ++++- src/gufo/err/middleware/errorinfo.py | 54 ++++++ tests/test_codec.py | 279 +++++++++++++++++++++++++++ tests/test_compressor.py | 73 +++++++ tests/test_error_info.py | 91 +++++++++ tests/test_traceback.py | 2 +- 10 files changed, 911 insertions(+), 5 deletions(-) create mode 100644 src/gufo/err/codec.py create mode 100644 src/gufo/err/compressor.py create mode 100644 src/gufo/err/middleware/errorinfo.py create mode 100644 tests/test_codec.py create mode 100644 tests/test_compressor.py create mode 100644 tests/test_error_info.py diff --git a/docs/changes.md b/docs/changes.md index d5c7ed0..8968595 100644 --- a/docs/changes.md +++ b/docs/changes.md @@ -1,5 +1,13 @@ # Changes +## 0.2.0 + +* `ErrorInfo` JSON serialization/deserialization. +* ErrorInfoMiddleware to write collected errors to JSON files. +* New Err.setup options: + * `error_info_path` + * `error_info_compress` + ## 0.1.1 * `__version__` attribute. diff --git a/src/gufo/err/__init__.py b/src/gufo/err/__init__.py index 30308ab..3231b09 100644 --- a/src/gufo/err/__init__.py +++ b/src/gufo/err/__init__.py @@ -18,4 +18,4 @@ from .abc.failfast import BaseFailFast # noqa from .abc.middleware import BaseMiddleware # noqa -__version__: str = "0.1.1" +__version__: str = "0.2.0" diff --git a/src/gufo/err/codec.py b/src/gufo/err/codec.py new file mode 100644 index 0000000..3afda80 --- /dev/null +++ b/src/gufo/err/codec.py @@ -0,0 +1,243 @@ +# --------------------------------------------------------------------- +# Gufo Err: Serialize/deserialize +# --------------------------------------------------------------------- +# Copyright (C) 2022, Gufo Labs +# --------------------------------------------------------------------- + +# Python modules +from typing import Dict, Any, Union, Tuple +import json +import uuid +import datetime + +# Gufo Labs modules +from .types import ErrorInfo, FrameInfo, SourceInfo + +CODEC_TYPE = "errorinfo" +CURRENT_VERSION = "1.0" + + +def to_dict(info: ErrorInfo) -> Dict[str, Any]: + """ + Serialize ErrorInfo to a dict of primitive types. + + Args: + info: ErrorInfo instance. + + Returns: + Dict of primitive types (str, int, float). + """ + + def q_x_class(e: BaseException) -> str: + """ + Get exception class. + + Args: + e: Exception instance + + Returns: + Serialized exception class name + """ + mod = e.__class__.__module__ + ncls = e.__class__.__name__ + if mod == "builtins": + return ncls + return f"{mod}.{ncls}" + + def q_var(x: Any) -> Union[str, int, float]: + """ + Convert variable to the JSON-encodable form. + + Args: + x: Exception argument + + Returns: + JSON-serializeable form of argument + """ + if isinstance(x, (int, float, str)): + return x + return str(x) + + def q_frame_info(fi: FrameInfo) -> Dict[str, Any]: + """ + Convert FrameInfo into JSON-serializeable form. + + Args: + fi: FrameInfo instance + + Returns: + Serialized dict + """ + r = { + "name": fi.name, + "module": fi.module, + "locals": {x: q_var(y) for x, y in fi.locals.items()}, + } + if fi.source: + r["source"] = q_source(fi.source) + return r + + def q_source(si: SourceInfo) -> Dict[str, Any]: + """ + Convert SourceInfo into JSON-serializeable form. + + Args: + si: SourceInfo instance + + Returns: + Serialized dict + """ + return { + "file_name": si.file_name, + "first_line": si.first_line, + "current_line": si.current_line, + "lines": si.lines, + } + + def q_exception(e: BaseException) -> Dict[str, Any]: + """ + Convery exception into JSON-serializeable form. + + Args: + e: BaseException instance + + Returns: + Serialized dict + """ + return { + "class": q_x_class(e), + "args": [q_var(x) for x in e.args], + } + + r = { + "$type": CODEC_TYPE, + "$version": CURRENT_VERSION, + "name": info.name, + "version": info.version, + "fingerprint": str(info.fingerprint), + "exception": q_exception(info.exception), + "stack": [q_frame_info(x) for x in info.stack], + } + if info.timestamp: + r["timestamp"] = info.timestamp.isoformat() + # @todo: stack + return r + + +def to_json(info: ErrorInfo) -> str: + """ + Serialize ErrorInfo to JSON string. + + Args: + info: ErrorInfo instance. + + Returns: + json-encoded string. + """ + return json.dumps(to_dict(info)) + + +def from_dict(data: Dict[str, Any]) -> ErrorInfo: + """ + Deserealize Dict to ErrorInfo. + + Args: + data: Result of to_dict + + Returns: + ErrorInfo instance + """ + + def get(d: Dict[str, Any], name: str) -> Any: + """ + Get key from dict or raise ValueError if not found. + + Args: + d: Data dictionary + name: Key name + + Returns: + Value + """ + x = d.get(name, None) + if x is None: + raise ValueError(f"{name} is required") + return x + + def get_fi(d: Dict[str, Any]) -> FrameInfo: + if d.get("source"): + source = get_si(d["source"]) + else: + source = None + return FrameInfo( + name=get(d, "name"), + module=get(d, "module"), + locals=get(d, "locals"), + source=source, + ) + + def get_si(d: Dict[str, Any]) -> SourceInfo: + return SourceInfo( + file_name=get(d, "file_name"), + first_line=get(d, "first_line"), + current_line=get(d, "current_line"), + lines=get(d, "lines"), + ) + + # Check incoming data is dict + if not isinstance(data, dict): + raise ValueError("dict required") + # Check data has proper type signature + ci_type = get(data, "$type") + if ci_type != CODEC_TYPE: + raise ValueError("Invalid $type") + # Check version + ci_version = get(data, "$version") + if ci_version != CURRENT_VERSION: + raise ValueError("Unknown $version") + # Process timestamp + src_ts = data.get("timestamp") + if src_ts: + ts = datetime.datetime.fromisoformat(src_ts) + else: + ts = None + # Exception + exc = get(data, "exception") + # Stack + stack = [get_fi(x) for x in get(data, "stack")] + # Set exception stub + return ErrorInfo( + name=get(data, "name"), + version=get(data, "version"), + fingerprint=uuid.UUID(get(data, "fingerprint")), + timestamp=ts, + stack=stack, + exception=ExceptionStub(kls=exc["class"], args=exc["args"]), + ) + + +def from_json(data: str) -> ErrorInfo: + """ + Deserialize ErrorInfo from JSON string. + + Args: + data: JSON string + + Returns: + ErrorInfo instance + """ + return from_dict(json.loads(data)) + + +class ExceptionStub(Exception): + """ + Stub to deserialized exceptions. + + Args: + kls: Exception class name + args: Exception arguments + """ + + def __init__(self, kls: str, args: Tuple[Any, ...]) -> None: + self.kls = kls + self.args = args diff --git a/src/gufo/err/compressor.py b/src/gufo/err/compressor.py new file mode 100644 index 0000000..07d553f --- /dev/null +++ b/src/gufo/err/compressor.py @@ -0,0 +1,121 @@ +# --------------------------------------------------------------------- +# Gufo Err: ErrorInfoMiddleware +# --------------------------------------------------------------------- +# Copyright (C) 2022, Gufo Labs +# --------------------------------------------------------------------- + +# Python modules +from typing import Optional, Tuple, Dict, Callable +import os + + +class Compressor(object): + """ + Compressor/decompressor class. + Use .encode() to compress data and .decode() to decompress. + + Args: + format: Compression algorithm. One of: + * `None` - do not compress + * `gz` - GZip + * `bz2` - BZip2 + * `xz` - LZMA/xz + """ + + FORMATS: Dict[ + Optional[str], + Tuple[Callable[[bytes], bytes], Callable[[bytes], bytes]], + ] + + def __init__(self, format: Optional[str] = None) -> None: + try: + self.encode, self.decode = self.FORMATS[format] + except KeyError: + raise ValueError(f"Unsupported format: {format}") + if format is None: + self.suffix = "" + else: + self.suffix = f".{format}" + + @classmethod + def autodetect(cls, path: str) -> "Compressor": + """ + Returns Compressor instance for given format. + + Args: + path: File path + + Returns: + Compressor instance + """ + return Compressor(format=cls.get_format(path)) + + @classmethod + def get_format(cls, path: str) -> Optional[str]: + """ + Auto-detect format from path. + + Args: + path: File path. + + Returns: + `format` parameter. + """ + _, ext = os.path.splitext(path) + if ext.startswith("."): + fmt = ext[1:] + if fmt in cls.FORMATS: + return fmt + return None + + @staticmethod + def encode_none(data: bytes) -> bytes: + return data + + @staticmethod + def decode_none(data: bytes) -> bytes: + return data + + @staticmethod + def encode_gz(data: bytes) -> bytes: + import gzip + + return gzip.compress(data) + + @staticmethod + def decode_gz(data: bytes) -> bytes: + import gzip + + return gzip.decompress(data) + + @staticmethod + def encode_bz2(data: bytes) -> bytes: + import bz2 + + return bz2.compress(data) + + @staticmethod + def decode_bz2(data: bytes) -> bytes: + import bz2 + + return bz2.decompress(data) + + @staticmethod + def encode_xz(data: bytes) -> bytes: + import lzma + + return lzma.compress(data) + + @staticmethod + def decode_xz(data: bytes) -> bytes: + import lzma + + return lzma.decompress(data) + + +Compressor.FORMATS = { + None: (Compressor.encode_none, Compressor.decode_none), + "gz": (Compressor.encode_gz, Compressor.decode_gz), + "bz2": (Compressor.encode_bz2, Compressor.decode_bz2), + "xz": (Compressor.encode_xz, Compressor.decode_xz), +} diff --git a/src/gufo/err/err.py b/src/gufo/err/err.py index ff12081..6945daf 100644 --- a/src/gufo/err/err.py +++ b/src/gufo/err/err.py @@ -121,6 +121,8 @@ def setup( fail_fast_code: int = DEFAULT_EXIT_CODE, middleware: Optional[Iterable[BaseMiddleware]] = None, format: Optional[str] = "terse", + error_info_path: Optional[str] = None, + error_info_compress: Optional[str] = None, ) -> "Err": """ Setup error handling singleton. Must be called @@ -148,6 +150,17 @@ def setup( Instances are evaluated in the order of appearance. format: If not None install TracebackMiddleware for given output format. + error_info_path: If not None install ErrorInfoMiddleware. + `error_info_path` should point to a writable directories, + in which the error info files to be written. + error_info_compress: Used only with `error_info_path`. + Set error info compression method. One of: + + * `None` - do not compress + * `gz` - GZip + * `bz2` - BZip2 + * `xz` - LZMA/xz + Returns: Err instance. """ @@ -174,11 +187,19 @@ def setup( self.__failfast_chain = [] # Initialize response chain if middleware: - self.__middleware_chain = self.__default_middleware(format=format) + self.__middleware_chain = self.__default_middleware( + format=format, + error_info_path=error_info_path, + error_info_compress=error_info_compress, + ) for resp in middleware: self.add_middleware(resp) else: - self.__middleware_chain = self.__default_middleware(format=format) + self.__middleware_chain = self.__default_middleware( + format=format, + error_info_path=error_info_path, + error_info_compress=error_info_compress, + ) # Mark as initialized self.__initialized = True return self @@ -310,7 +331,10 @@ def add_middleware(self, mw: BaseMiddleware) -> None: self.__middleware_chain.append(mw) def __default_middleware( - self, format: Optional[str] = None + self, + format: Optional[str] = None, + error_info_path: Optional[str] = None, + error_info_compress: Optional[str] = None, ) -> List[BaseMiddleware]: """ Get default middleware chain. @@ -318,12 +342,25 @@ def __default_middleware( Args: format: traceback format. See TracebackMiddleware for details. Do not configure tracebacks if None. + error_info_path: Directory path to write error info. + See ErrorInfoMiddleware for details. + Do not configure middleware if None. + error_info_compress: Error info compression algorithm. Used along + with `error_info_path`. """ r: List[BaseMiddleware] = [] if format is not None: from .middleware.traceback import TracebackMiddleware r.append(TracebackMiddleware(format=format)) + if error_info_path is not None: + from .middleware.errorinfo import ErrorInfoMiddleware + + r.append( + ErrorInfoMiddleware( + path=error_info_path, compress=error_info_compress + ) + ) return r diff --git a/src/gufo/err/middleware/errorinfo.py b/src/gufo/err/middleware/errorinfo.py new file mode 100644 index 0000000..4481c9c --- /dev/null +++ b/src/gufo/err/middleware/errorinfo.py @@ -0,0 +1,54 @@ +# --------------------------------------------------------------------- +# Gufo Err: ErrorInfoMiddleware +# --------------------------------------------------------------------- +# Copyright (C) 2022, Gufo Labs +# --------------------------------------------------------------------- + +# Python modules +from typing import Optional +import os + +# Gufo Labs modules +from ..abc.middleware import BaseMiddleware +from ..types import ErrorInfo +from ..logger import logger +from ..codec import to_json +from ..compressor import Compressor + + +class ErrorInfoMiddleware(BaseMiddleware): + """ + Dump error to JSON file. + + Args: + path: Path to directory to write error info. + compress: Compression algorithm. One of: + * `None` - do not compress + * `gz` - GZip + * `bz2` - BZip2 + * `xz` - LZMA/xz + """ + + def __init__(self, path: str, compress: Optional[str] = None) -> None: + super().__init__() + self.path = path + # Check permissions + if not os.access(self.path, os.W_OK): + raise ValueError(f"{path} is not writable") + self.compressor = Compressor(format=compress) + + def process(self, info: ErrorInfo) -> None: + # ErrorInfo path + fn = os.path.join( + self.path, f"{info.fingerprint}.json{self.compressor.suffix}" + ) + # Check if path is already exists + if os.path.exists(fn): + logger.error( + "Error %s is already registered. Skipping.", info.fingerprint + ) + return + # Write erorr info + logger.error("Writing error info into %s", fn) + with open(fn, "wb") as f: + f.write(self.compressor.encode(to_json(info).encode("utf-8"))) diff --git a/tests/test_codec.py b/tests/test_codec.py new file mode 100644 index 0000000..db52318 --- /dev/null +++ b/tests/test_codec.py @@ -0,0 +1,279 @@ +# --------------------------------------------------------------------- +# Gufo Err: serde tests +# --------------------------------------------------------------------- +# Copyright (C) 2022, Gufo Labs +# --------------------------------------------------------------------- + +# Python modules +import os +import uuid +import datetime +from typing import Any, Dict + +# Third-party modules +import pytest + +# Gufo Labs modules +from gufo.err.types import FrameInfo, SourceInfo, ErrorInfo +from gufo.err.codec import ( + from_json, + to_dict, + to_json, + from_dict, + ExceptionStub, +) + +TZ = datetime.timezone(datetime.timedelta(hours=1), "CEST") + +SAMPLE = ErrorInfo( + name="oops", + version="1.0", + fingerprint=uuid.UUID("be8ccd86-3661-434c-8569-40dd65d9860a"), + exception=RuntimeError("oops"), + timestamp=datetime.datetime(2022, 3, 22, 7, 21, 29, 215827, tzinfo=TZ), + stack=[ + FrameInfo( + name="test_iter_frames", + module="tests.test_frames", + source=SourceInfo( + file_name=os.path.join("tests", "test_frames.py"), + current_line=125, + first_line=118, + lines=[ + "", + "", + "def test_iter_frames():", + ' """', + " Call the function which raises an exception", + ' """', + " try:", + " entry()", + ' assert False, "No trace"', + " except RuntimeError:", + " frames = list(iter_frames(exc_traceback()))", + " assert frames == SAMPLE_FRAMES", + ], + ), + locals={}, + ), + FrameInfo( + name="entry", + module="tests.sample.trace", + source=SourceInfo( + file_name=os.path.join("tests", "sample", "trace.py"), + current_line=14, + first_line=7, + lines=[ + " x += 1", + " oops()", + "", + "", + "def entry():", + " s = 2", + " s += 1", + " to_oops()", + ], + ), + locals={"s": 3}, + ), + FrameInfo( + name="to_oops", + module="tests.sample.trace", + source=SourceInfo( + file_name=os.path.join("tests", "sample", "trace.py"), + current_line=8, + first_line=1, + lines=[ + "def oops():", + ' raise RuntimeError("oops")', + "", + "", + "def to_oops():", + " x = 1", + " x += 1", + " oops()", + "", + "", + "def entry():", + " s = 2", + " s += 1", + " to_oops()", + ], + ), + locals={"x": 2}, + ), + FrameInfo( + name="oops", + module="tests.sample.trace", + source=SourceInfo( + file_name=os.path.join("tests", "sample", "trace.py"), + current_line=2, + first_line=1, + lines=[ + "def oops():", + ' raise RuntimeError("oops")', + "", + "", + "def to_oops():", + " x = 1", + " x += 1", + " oops()", + "", + ], + ), + locals={}, + ), + ], +) + +SAMPLE_DICT: Dict[str, Any] = { + "$type": "errorinfo", + "$version": "1.0", + "fingerprint": "be8ccd86-3661-434c-8569-40dd65d9860a", + "name": "oops", + "timestamp": "2022-03-22T07:21:29.215827+01:00", + "version": "1.0", + "exception": {"class": "RuntimeError", "args": ["oops"]}, + "stack": [ + { + "name": "test_iter_frames", + "module": "tests.test_frames", + "locals": {}, + "source": { + "file_name": "tests/test_frames.py", + "current_line": 125, + "first_line": 118, + "lines": [ + "", + "", + "def test_iter_frames():", + ' """', + " Call the function which raises an exception", + ' """', + " try:", + " entry()", + ' assert False, "No trace"', + " except RuntimeError:", + " frames = list(iter_frames(exc_traceback()))", + " assert frames == SAMPLE_FRAMES", + ], + }, + }, + { + "name": "entry", + "module": "tests.sample.trace", + "locals": {"s": 3}, + "source": { + "file_name": "tests/sample/trace.py", + "current_line": 14, + "first_line": 7, + "lines": [ + " x += 1", + " oops()", + "", + "", + "def entry():", + " s = 2", + " s += 1", + " to_oops()", + ], + }, + }, + { + "name": "to_oops", + "module": "tests.sample.trace", + "locals": {"x": 2}, + "source": { + "file_name": "tests/sample/trace.py", + "current_line": 8, + "first_line": 1, + "lines": [ + "def oops():", + ' raise RuntimeError("oops")', + "", + "", + "def to_oops():", + " x = 1", + " x += 1", + " oops()", + "", + "", + "def entry():", + " s = 2", + " s += 1", + " to_oops()", + ], + }, + }, + { + "name": "oops", + "module": "tests.sample.trace", + "locals": {}, + "source": { + "file_name": "tests/sample/trace.py", + "current_line": 2, + "first_line": 1, + "lines": [ + "def oops():", + ' raise RuntimeError("oops")', + "", + "", + "def to_oops():", + " x = 1", + " x += 1", + " oops()", + "", + ], + }, + }, + ], +} + + +def test_to_dict(): + out = to_dict(SAMPLE) + assert out == SAMPLE_DICT + + +def test_to_json(): + out = to_json(SAMPLE) + isinstance(out, str) + + +def test_from_dict(): + out = from_dict(SAMPLE_DICT) + assert isinstance(out.exception, ExceptionStub) + assert out.exception.kls == "RuntimeError" + assert out.exception.args == ("oops",) + out.exception = SAMPLE.exception + assert out == SAMPLE + + +def test_from_json(): + src = to_json(SAMPLE) + out = from_json(src) + assert isinstance(out.exception, ExceptionStub) + assert out.exception.kls == "RuntimeError" + assert out.exception.args == ("oops",) + out.exception = SAMPLE.exception + assert out == SAMPLE + + +def test_from_dict_no_dict(): + with pytest.raises(ValueError): + from_dict(None) + + +def test_from_dict_invalid_type(): + with pytest.raises(ValueError): + from_dict({"$type": "foobar"}) + + +def test_from_dict_invalid_version(): + with pytest.raises(ValueError): + from_dict({"$type": "errorinfo", "$version": "-1"}) + + +def test_from_dict_no_exc(): + with pytest.raises(ValueError): + from_dict({"$type": "errorinfo", "$version": "1.0"}) diff --git a/tests/test_compressor.py b/tests/test_compressor.py new file mode 100644 index 0000000..576a6a3 --- /dev/null +++ b/tests/test_compressor.py @@ -0,0 +1,73 @@ +# --------------------------------------------------------------------- +# Gufo Err: test Compressor +# --------------------------------------------------------------------- +# Copyright (C) 2022, Gufo Labs +# --------------------------------------------------------------------- + +# Third-party modules +import pytest + +# Gufo Labs Modules +from gufo.err.compressor import Compressor + + +def test_invalid_format(): + with pytest.raises(ValueError): + Compressor(format="rar") + + +@pytest.mark.parametrize( + ["path", "expected"], + [ + ("/a/b/c/xxx.json", None), + ("/a/b/c/xxx.json.gz", "gz"), + ("/a/b/c/xxx.json.bz2", "bz2"), + ("/a/b/c/xxx.json.xz", "xz"), + ("/a/b/c/xxx.json.rar", None), + ], +) +def test_get_format(path: str, expected: str) -> None: + assert Compressor.get_format(path) == expected + + +@pytest.mark.parametrize( + ["path", "expected"], + [ + ("/a/b/c/xxx.json", None), + ("/a/b/c/xxx.json.gz", "gz"), + ("/a/b/c/xxx.json.bz2", "bz2"), + ("/a/b/c/xxx.json.xz", "xz"), + ("/a/b/c/xxx.json.rar", None), + ], +) +def test_autodetect(path: str, expected: str) -> None: + c = Compressor.autodetect(path) + assert isinstance(c, Compressor) + src = b"12345" + cdata = c.encode(src) + assert c.decode(cdata) == src + + +@pytest.mark.parametrize( + ["fmt", "data"], + [ + (None, b"12345"), + ( + "gz", + b"12345", + ), + ( + "bz2", + b"12345", + ), + ( + "xz", + b"12345", + ), + ], +) +def test_compressor(fmt: str, data: bytes): + c = Compressor(format=fmt) + c_data = c.encode(data) + s_data = c.decode(c_data) + assert s_data == data diff --git a/tests/test_error_info.py b/tests/test_error_info.py new file mode 100644 index 0000000..ccc30db --- /dev/null +++ b/tests/test_error_info.py @@ -0,0 +1,91 @@ +# --------------------------------------------------------------------- +# Gufo Err: test ErrorInfoMiddleware +# --------------------------------------------------------------------- +# Copyright (C) 2022, Gufo Labs +# --------------------------------------------------------------------- + +# Third-party modules +import pytest +import re + +# Gufo Labs modules +from gufo.err import Err +from gufo.err.compressor import Compressor +from gufo.err.codec import from_json +from .util import log_capture + +rx_log_path = re.compile(r"Writing error info into (\S+)") + + +def test_unwritable_path(): + with pytest.raises(ValueError): + Err().setup(error_info_path="/a/b/c/d") + + +def test_invalid_compress(tmpdir): + with pytest.raises(ValueError): + Err().setup( + error_info_path=tmpdir.mkdir("errinfo"), error_info_compress="rar" + ) + + +@pytest.mark.parametrize(["compress"], [(None,), ("gz",), ("bz2",), ("xz",)]) +def test_compress(tmpdir, compress): + err_info_path = tmpdir.mkdir("errinfo") + # Setup + err = Err().setup( + format=None, + error_info_path=err_info_path, + error_info_compress=compress, + ) + # Generate error + try: + raise RuntimeError("oops") + except RuntimeError: + with log_capture() as buffer: + err.process() + output = buffer.getvalue() + # Check error info + match = rx_log_path.search(output) + assert match + ei_file = match.group(1) + if compress is None: + assert ei_file.endswith(".json") + else: + assert ei_file.endswith(f".json.{compress}") + # Check crashinfo is written + assert len(err_info_path.listdir()) == 1 + ei_path = err_info_path.listdir()[0] + # Compare to logged + assert ei_path == ei_file + # Check compressor suffix + compressor = Compressor(format=compress) + assert ei_file.endswith(compressor.suffix) + # Try to read file + with open(ei_path, "rb") as f: + data = compressor.decode(f.read()).decode("utf-8") + ei = from_json(data) + # Check error info + fn = f"{ei.fingerprint}.json{compressor.suffix}" + assert ei_file.endswith(fn) + + +@pytest.mark.parametrize(["compress"], [(None,), ("gz",), ("bz2",), ("xz",)]) +def test_seen(tmpdir, compress): + err_info_path = tmpdir.mkdir("errinfo") + # Setup + err = Err().setup( + format=None, + error_info_path=err_info_path, + error_info_compress=compress, + ) + # Generate error + try: + raise RuntimeError("oops") + except RuntimeError: + with log_capture() as buffer: + err.process() + err.process() # Must seen the error + output = buffer.getvalue() + # Check error info + assert "is already registered" in output diff --git a/tests/test_traceback.py b/tests/test_traceback.py index 1092b38..70313b8 100644 --- a/tests/test_traceback.py +++ b/tests/test_traceback.py @@ -1,5 +1,5 @@ # --------------------------------------------------------------------- -# Gufo Err: test TracebackResponse +# Gufo Err: test TracebackMiddleware # --------------------------------------------------------------------- # Copyright (C) 2022, Gufo Labs # ---------------------------------------------------------------------