diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3edaedb..4fe9b4b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,13 +23,10 @@ repos: - id: pytest-coverage name: pytest-coverage entry: > - powershell -Command " - $env:UV_PROJECT_ENVIRONMENT = '.venv-3.12'; - uv run --python 3.12 pytest --cov=offline_debug; - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; - $env:UV_PROJECT_ENVIRONMENT = '.venv-3.13'; - uv run --python 3.13 pytest --cov=offline_debug --cov-append --cov-report=term --cov-fail-under=100 - " + bash -c ' + UV_PROJECT_ENVIRONMENT=.venv-3.12 uv run --python 3.12 pytest --cov=offline_debug && + UV_PROJECT_ENVIRONMENT=.venv-3.13 uv run --python 3.13 pytest --cov=offline_debug --cov-append --cov-report=term --cov-fail-under=100 + ' language: system types: [python] pass_filenames: false diff --git a/README.md b/README.md index 2a417a0..4bc9c76 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ if isinstance(data, ExceptionGroupData): - **Python 3.13 Compatibility**: Leverages PEP 667 features where `f_locals` is a write-through proxy, allowing for accurate local variable restoration. - **Support python 3.12 as well** -- **Robust Serialization**: +- **Resilient Serialization**: - `pickle` is used for exceptions and variables. - `marshal` is used for code objects. - Non-picklable items are gracefully handled by storing their `repr`. diff --git a/offline_debug/_inner/_pickle_helpers.py b/offline_debug/_inner/_pickle_helpers.py new file mode 100644 index 0000000..0e984a8 --- /dev/null +++ b/offline_debug/_inner/_pickle_helpers.py @@ -0,0 +1,144 @@ +""" +Pickling for exceptions that vanilla pickle cannot reconstruct. + +``BaseException`` reduces to ``(type(self), self.args, state)``, so pickle +rebuilds an exception by calling ``Cls(*self.args)`` and then applying state. +Exceptions whose ``__init__``/``__new__`` takes required keyword-only arguments +(and which do not populate ``self.args`` via ``super().__init__``) pickle fine +but fail on load with a ``TypeError``. Since a traceback-capture library must +serialize whatever the user raised, we take over reconstruction for such +exceptions. + +We keep the takeover as narrow as possible so well-behaved exceptions keep +their vanilla pickle behavior: + +- Classes that customize the pickle protocol (``__reduce__``, ``__setstate__``, + ...) are left alone; their hooks know how to rebuild them (e.g. ``OSError``). +- Classes whose constructors are all C-level (every builtin exception) are left + alone; ``Cls(*args)`` is guaranteed to work and restores C-level state such + as ``StopIteration.value`` that lives outside ``__dict__``. +- Only classes with a Python-defined ``__init__``/``__new__`` — the only kind + that can demand required keyword-only arguments — are reconstructed by us, + and even then reconstruction first retries normal construction and only + falls back to ``__new__`` (bypassing the constructor) when it fails. + +Exception groups get a dedicated reconstructor because a bare +``cls.__new__(cls)`` is not enough for them: ``BaseExceptionGroup.__new__`` +requires ``message``/``exceptions`` to build a valid group for any subclass. + +.. warning:: + The path of this module and the names of :func:`reconstruct_exception` and + :func:`reconstruct_exception_group` are embedded in every dump that takes + the takeover path. Renaming or moving them makes previously saved dumps + unloadable, so treat them as part of the on-disk format. +""" + +import contextlib +import io +import pickle +import types +from typing import IO, Any + +# Pickle protocol hooks that let a class control its own serialization. +# If an exception class customizes any of these, we must not override its +# reduction: the class (e.g. OSError, ImportError) knows how to rebuild itself. +_PICKLE_PROTOCOL_HOOKS = ( + "__reduce_ex__", + "__reduce__", + "__getstate__", + "__setstate__", + "__getnewargs__", + "__getnewargs_ex__", +) + + +def _customizes_pickling(cls: type[BaseException]) -> bool: + """Whether ``cls`` overrides any pickle protocol hook of ``BaseException``.""" + return any( + getattr(cls, hook, None) is not getattr(BaseException, hook, None) + for hook in _PICKLE_PROTOCOL_HOOKS + ) + + +def _has_python_constructor(cls: type[BaseException]) -> bool: + """ + Whether ``cls`` has a Python-defined ``__init__`` or ``__new__``. + + Builtin exceptions expose C slot wrappers here, and for them default pickle + reconstruction always works. Only a Python-level constructor can introduce + required keyword-only arguments that break ``Cls(*args)`` at load time. + """ + return isinstance(cls.__init__, types.FunctionType) or isinstance( + cls.__new__, types.FunctionType + ) + + +def reconstruct_exception( + cls: type[BaseException], args: tuple[Any, ...], state: dict[str, Any] | None +) -> BaseException: + """Rebuild an exception, bypassing its constructor only if it rejects ``args``.""" + try: + exc = cls(*args) + except Exception: # noqa: BLE001 - a rejecting constructor is exactly the case we handle + exc = BaseException.__new__(cls) + # Suppressed failures happen when e.g. `args` is shadowed by a read-only property. + with contextlib.suppress(Exception): + exc.args = args + if state: + exc.__dict__.update(state) + return exc + + +def reconstruct_exception_group( + cls: type[BaseExceptionGroup[Any]], + message: str, + exceptions: tuple[BaseException, ...], + state: dict[str, Any] | None, +) -> BaseExceptionGroup[Any]: + """ + Rebuild an exception group, bypassing its constructor only when necessary. + + ``BaseExceptionGroup.__new__`` establishes ``message``/``exceptions`` for any + subclass, so when normal construction fails (e.g. a subclass demanding extra + required keyword-only arguments) we call it directly. + """ + try: + exc = cls(message, exceptions) + except Exception: # noqa: BLE001 - a rejecting constructor is exactly the case we handle + exc = BaseExceptionGroup.__new__(cls, message, exceptions) + if state: + exc.__dict__.update(state) + return exc + + +class CustomExceptionPickler(pickle.Pickler): + """Pickler that takes over reconstruction of constructor-rejecting exceptions.""" + + # The inline suppression below is needed because this returns NotImplemented to + # fall back to default reduction, which the stdlib stub's return type omits. + def reducer_override(self, obj: object, /) -> object: # ty: ignore[invalid-method-override] + if not isinstance(obj, BaseException): + return NotImplemented + cls = type(obj) + if _customizes_pickling(cls) or not _has_python_constructor(cls): + # Default reduction is guaranteed to round-trip; taking over would + # lose state the class restores itself (e.g. OSError.errno). + return NotImplemented + state = obj.__dict__.copy() or None + # Groups need their dedicated reconstructor: message/exceptions live + # behind __new__ rather than a settable `args`. + if isinstance(obj, BaseExceptionGroup): + return reconstruct_exception_group, (cls, obj.message, obj.exceptions, state) + return reconstruct_exception, (cls, obj.args, state) + + +def exception_safe_dump(obj: object, file: IO[bytes]) -> None: + """Serialize ``obj`` to an open binary ``file`` using :class:`CustomExceptionPickler`.""" + CustomExceptionPickler(file).dump(obj) + + +def exception_safe_dumps(obj: object) -> bytes: + """Serialize ``obj`` to bytes using :class:`CustomExceptionPickler`.""" + buf = io.BytesIO() + exception_safe_dump(obj, buf) + return buf.getvalue() diff --git a/offline_debug/_inner/load_traceback.py b/offline_debug/_inner/load_traceback.py index daa121b..88d156a 100644 --- a/offline_debug/_inner/load_traceback.py +++ b/offline_debug/_inner/load_traceback.py @@ -8,6 +8,7 @@ from pathlib import Path from types import CodeType +from offline_debug._inner._pickle_helpers import reconstruct_exception_group from offline_debug._inner.c_api import ( create_frame, link_frame, @@ -39,9 +40,13 @@ def _reconstruct_exc_data(data: ExceptionData) -> BaseException: if isinstance(data, ExceptionGroupData) and isinstance(exc, BaseExceptionGroup): inner_excs = [_reconstruct_exc_data(e) for e in data.exceptions] - # We must use derive to create a new ExceptionGroup with reconstructed inner exceptions. - # The exceptions that are inside the unpickled exc object are have incomplete data. - exc = exc.derive(inner_excs) + # The exceptions inside the unpickled exc object have incomplete data, so + # rebuild the group around the fully reconstructed ones. We must not use + # derive() for this: its default implementation returns a plain + # ExceptionGroup, dropping the subclass type and its custom state. + exc = reconstruct_exception_group( + type(exc), exc.message, tuple(inner_excs), exc.__dict__.copy() or None + ) reconstructed_frames: list[tuple[types.FrameType, FrameData]] = [] for f_data in data.tb_frames: diff --git a/offline_debug/_inner/save_traceback.py b/offline_debug/_inner/save_traceback.py index ae15cc5..46f8358 100644 --- a/offline_debug/_inner/save_traceback.py +++ b/offline_debug/_inner/save_traceback.py @@ -6,6 +6,7 @@ from io import BytesIO from pathlib import Path +from offline_debug._inner._pickle_helpers import exception_safe_dump, exception_safe_dumps from offline_debug._inner.models import ExceptionData, ExceptionGroupData, FrameData # Internal attributes that are either unpicklable or redundant in a new process. @@ -24,24 +25,42 @@ def _get_stack_depth(frame: types.FrameType) -> int: return depth -def _filter_dict(d: dict) -> dict: - """Filter dictionary to include only picklable items.""" +def _filter_dict(d: dict, roundtrip_cache: dict[int, str | None]) -> dict: + """ + Filter dictionary to include only items that survive a pickle round-trip. + + ``roundtrip_cache`` maps ``id(value)`` to ``None`` (survives) or a placeholder + string, so a value shared across frames (e.g. module globals) is only checked + once per save. The cached objects stay alive for the whole save because the + frames still reference them, so the ids are stable. + """ result = {} for k, v in d.items(): if k in _INTERNAL_ATTRIBUTES_TO_SKIP: continue - try: - # We must verify if the value is picklable because many globals - # (like open file handles, database connections, or modules) - # cannot be saved to disk. - pickle.dumps(v) - result[k] = v - except Exception: # noqa: BLE001 - result[k] = f"" + cache_key = id(v) + if cache_key not in roundtrip_cache: + try: + # We must verify that the value survives a full pickle round-trip + # because many globals (like open file handles, database connections, + # or modules) cannot be saved to disk, and some values pickle but fail + # to unpickle (e.g. a custom __reduce__ whose callable raises on load). + # Such values would otherwise break the entire load, so we replace + # them with a placeholder. We use the same pickler that serializes + # these dicts so the check reflects what will actually be written. + pickle.loads(exception_safe_dumps(v)) # noqa: S301 + roundtrip_cache[cache_key] = None + except BaseException: # noqa: BLE001 - even a KeyboardInterrupt raised by a + # value's reconstruction must not abort capturing the traceback. + roundtrip_cache[cache_key] = f"" + placeholder = roundtrip_cache[cache_key] + result[k] = v if placeholder is None else placeholder return result -def _serialize_exc_data(exc: BaseException) -> ExceptionData: +def _serialize_exc_data( + exc: BaseException, roundtrip_cache: dict[int, str | None] +) -> ExceptionData: """Recursively serialize exception data into dataclasses.""" tb_frames: list[FrameData] = [] curr_tb = exc.__traceback__ @@ -60,8 +79,8 @@ def _serialize_exc_data(exc: BaseException) -> ExceptionData: tb_frames.append( FrameData( code=marshal.dumps(f.f_code), - globals=_filter_dict(f.f_globals), - locals=_filter_dict(f.f_locals), + globals=_filter_dict(f.f_globals, roundtrip_cache), + locals=_filter_dict(f.f_locals, roundtrip_cache), lasti=curr_tb.tb_lasti, lineno=curr_tb.tb_lineno, stack_depth=_get_stack_depth(f), @@ -71,14 +90,18 @@ def _serialize_exc_data(exc: BaseException) -> ExceptionData: curr_tb = curr_tb.tb_next try: - exc_pickle = pickle.dumps(exc) + exc_pickle = exception_safe_dumps(exc) + # A dump that cannot be loaded later is worse than a placeholder, so also + # verify the exception survives loading (e.g. a custom __reduce__ whose + # reconstruction fails only at load time). + pickle.loads(exc_pickle) # noqa: S301 except Exception: # noqa: BLE001 - exc_pickle = pickle.dumps( + exc_pickle = exception_safe_dumps( RuntimeError(f"Unpicklable exception {type(exc).__name__}: {exc!s}") ) - cause = _serialize_exc_data(exc.__cause__) if exc.__cause__ else None - context = _serialize_exc_data(exc.__context__) if exc.__context__ else None + cause = _serialize_exc_data(exc.__cause__, roundtrip_cache) if exc.__cause__ else None + context = _serialize_exc_data(exc.__context__, roundtrip_cache) if exc.__context__ else None if isinstance(exc, BaseExceptionGroup): return ExceptionGroupData( @@ -86,7 +109,7 @@ def _serialize_exc_data(exc: BaseException) -> ExceptionData: tb_frames=tb_frames, cause=cause, context=context, - exceptions=[_serialize_exc_data(e) for e in exc.exceptions], + exceptions=[_serialize_exc_data(e, roundtrip_cache) for e in exc.exceptions], ) return ExceptionData( @@ -99,15 +122,15 @@ def _serialize_exc_data(exc: BaseException) -> ExceptionData: def save_traceback(exc: BaseException, file: Path | BytesIO | None) -> ExceptionData: """Serialize an exception and its traceback to a file.""" - data = _serialize_exc_data(exc) + data = _serialize_exc_data(exc, roundtrip_cache={}) if file is None: return data if isinstance(file, Path): with file.open("wb") as f: - pickle.dump(data, f) + exception_safe_dump(data, f) elif isinstance(file, BytesIO): - pickle.dump(data, file) + exception_safe_dump(data, file) else: msg = f"Unexpected type for file {type(file).__name__}" raise TypeError(msg) diff --git a/pyproject.toml b/pyproject.toml index 427158c..a5bfa9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "offline-debug" -version = "0.3.0" +version = "0.3.1" description = "Debug exceptions offline by saving them to a dump and raising them at a later point." readme = "README.md" authors = [ diff --git a/tests/_inner/test_save_traceback.py b/tests/_inner/test_save_traceback.py index fa5b8f4..c6f90f8 100644 --- a/tests/_inner/test_save_traceback.py +++ b/tests/_inner/test_save_traceback.py @@ -76,6 +76,75 @@ def fail_with_unpicklable() -> Never: GLOBAL_VAR = "initial" +class RaisesKeyboardInterruptOnLoad: + """Object that pickles fine but raises KeyboardInterrupt when unpickled.""" + + def __init__(self) -> None: + """Store an instance attribute so unpickling actually calls __setstate__.""" + self.data = "payload" + + def __setstate__(self, _state: dict) -> Never: + """Simulate an interrupt fired from a value's reconstruction.""" + raise KeyboardInterrupt + + def __repr__(self) -> str: + """Stable repr for the placeholder assertion.""" + return "" + + +def _raise_on_load() -> Never: + msg = "cannot load" + raise TypeError(msg) + + +class LoadFailError(Exception): + """Exception that pickles fine but whose reconstruction fails at load time.""" + + def __reduce__(self) -> tuple: + """Reduce to a callable that raises when the pickle is loaded.""" + return (_raise_on_load, ()) + + +def test_keyboard_interrupt_during_value_check(tmp_path: Path) -> None: + """A BaseException raised while round-trip checking a value must not abort the save.""" + dump_file = tmp_path / "keyboard_interrupt.dump" + + def fail_with_interrupting_local() -> Never: + _obj = RaisesKeyboardInterruptOnLoad() + _ = locals()["_obj"] + msg = "Error with interrupting local" + raise ValueError(msg) + + try: + fail_with_interrupting_local() + except Exception as e: # noqa: BLE001 + save_traceback(e, dump_file) + + with pytest.raises(ValueError, match="Error with interrupting local") as exc_info: + load_traceback(dump_file) + + frames = get_frames(exc_info.tb) + f = next(f for f in frames if f.f_code.co_name == "fail_with_interrupting_local") + assert any(" None: + """An exception that dumps fine but cannot be loaded must degrade to RuntimeError.""" + dump_file = tmp_path / "load_fail_exc.dump" + + def raise_load_fail() -> Never: + msg = "Load fail" + raise LoadFailError(msg) + + try: + raise_load_fail() + except Exception as e: # noqa: BLE001 + save_traceback(e, dump_file) + + with pytest.raises(RuntimeError, match="Unpicklable exception LoadFailError: Load fail"): + load_traceback(dump_file) + + def test_global_variables_in_stack(tmp_path: Path) -> None: """Test that global variables are preserved in the stack.""" dump_file = tmp_path / "globals.dump" diff --git a/tests/test_exception_fidelity.py b/tests/test_exception_fidelity.py new file mode 100644 index 0000000..c98a0e5 --- /dev/null +++ b/tests/test_exception_fidelity.py @@ -0,0 +1,186 @@ +""" +Regression tests: well-behaved exceptions must keep their vanilla pickle fidelity. + +The custom pickler only takes over reconstruction for exceptions whose +Python-defined constructors can reject ``Cls(*args)`` at load time. Everything +else — builtin exceptions with C-level state, classes with their own pickle +protocol hooks — must round-trip exactly as vanilla pickle would. +""" + +from __future__ import annotations + +from io import BytesIO +from typing import Never, Self + +from offline_debug import load_traceback, save_traceback + +EXPECTED_ERRNO = 2 +EXPECTED_VALUE = 42 + + +def roundtrip(exc: BaseException) -> BaseException: + """Save an exception to a buffer and load it back without raising.""" + buffer = BytesIO() + save_traceback(exc, buffer) + buffer.seek(0) + return load_traceback(buffer, should_raise=False) + + +class SetstateError(Exception): + """Exception that relies on ``__setstate__`` to finish reconstruction.""" + + def __init__(self, message: str) -> None: + """Store an instance attribute so the pickle carries state to restore.""" + super().__init__(message) + self.message = message + + def __setstate__(self, state: dict[str, object] | None, /) -> None: + """Restore state and mark that the pickle protocol hook actually ran.""" + self.__dict__.update(state or {}) + self.restored = True + + +class SlottedError(Exception): + """Exception whose extra state lives in ``__slots__``, not ``__dict__``.""" + + __slots__ = ("code",) + + def __init__(self, code: int) -> None: + """Populate ``args`` via super() and keep ``code`` in a slot.""" + super().__init__(code) + self.code = code + + +class FrozenArgsError(Exception): + """Exception with a read-only ``args`` and a required keyword-only argument.""" + + args = property(lambda _self: ()) + + def __init__(self, *, message: str) -> None: + """Store the message without populating ``self.args``.""" + self.message = message + + +class CodedGroup(ExceptionGroup): + """Exception group subclass carrying extra state, with the default derive.""" + + code: int + + def __new__(cls, message: str, exceptions: list[Exception], *, code: int) -> Self: + """Attach ``code`` on the group created by ``BaseExceptionGroup.__new__``.""" + self = super().__new__(cls, message, exceptions) + self.code = code + return self + + def __init__(self, message: str, exceptions: list[Exception], *, code: int) -> None: + """Accept ``code`` so construction does not fall through to BaseException.""" + + +def test_oserror_attributes_preserved() -> None: + """OSError state lives at the C level and is restored by its own __reduce__.""" + + def raise_oserror() -> Never: + raise OSError(EXPECTED_ERRNO, "No such file or directory", "missing.txt") + + try: + raise_oserror() + except OSError as e: + loaded_exc = roundtrip(e) + + assert isinstance(loaded_exc, FileNotFoundError) + assert loaded_exc.errno == EXPECTED_ERRNO + assert loaded_exc.strerror == "No such file or directory" + assert loaded_exc.filename == "missing.txt" + + +def test_stopiteration_value_preserved() -> None: + """StopIteration.value is C-level state populated by the builtin __init__.""" + + def raise_stopiteration() -> Never: + raise StopIteration(EXPECTED_VALUE) + + try: + raise_stopiteration() + except StopIteration as e: + loaded_exc = roundtrip(e) + + assert isinstance(loaded_exc, StopIteration) + assert loaded_exc.value == EXPECTED_VALUE + + +def test_custom_setstate_honored() -> None: + """An exception's own pickle protocol hooks must not be bypassed.""" + + def raise_setstate_error() -> Never: + raise SetstateError("hello") + + try: + raise_setstate_error() + except SetstateError as e: + loaded_exc = roundtrip(e) + + assert isinstance(loaded_exc, SetstateError) + assert loaded_exc.restored is True + + +def test_slotted_exception_preserved() -> None: + """ + Slot state set by a Python __init__ must survive the round-trip. + + The takeover path applies (Python-defined __init__), so reconstruction must + retry normal construction — a bare ``__new__`` + ``__dict__`` restore would + silently drop the slot. + """ + + def raise_slotted() -> Never: + raise SlottedError(EXPECTED_VALUE) + + try: + raise_slotted() + except SlottedError as e: + loaded_exc = roundtrip(e) + + assert isinstance(loaded_exc, SlottedError) + assert loaded_exc.code == EXPECTED_VALUE + + +def test_readonly_args_exception() -> None: + """ + A read-only ``args`` must not abort reconstruction. + + The keyword-only ``message`` forces the ``__new__`` fallback, where assigning + ``args`` fails on the property; reconstruction must degrade gracefully and + still restore the instance state. + """ + + def raise_frozen_args() -> Never: + raise FrozenArgsError(message="hello") + + try: + raise_frozen_args() + except FrozenArgsError as e: + loaded_exc = roundtrip(e) + + assert isinstance(loaded_exc, FrozenArgsError) + assert loaded_exc.message == "hello" + + +def test_exception_group_subclass_without_derive() -> None: + """ + A group subclass must survive load without overriding ``derive``. + + The load path rebuilds groups around their reconstructed inner exceptions; + relying on the default ``derive`` would return a plain ExceptionGroup and + silently drop the subclass type and its custom state. + """ + + def raise_coded_group() -> Never: + raise CodedGroup("grp", [ValueError("a")], code=EXPECTED_VALUE) + + try: + raise_coded_group() + except CodedGroup as e: + loaded_exc = roundtrip(e) + + assert isinstance(loaded_exc, CodedGroup) + assert loaded_exc.code == EXPECTED_VALUE diff --git a/tests/test_exception_kwarg_arguments.py b/tests/test_exception_kwarg_arguments.py new file mode 100644 index 0000000..e5baeaf --- /dev/null +++ b/tests/test_exception_kwarg_arguments.py @@ -0,0 +1,88 @@ +"""Tests for exceptions whose __init__ takes required keyword-only arguments.""" + +from __future__ import annotations + +from io import BytesIO +from typing import Never, Self + +from offline_debug import load_traceback, save_traceback + +EXPECTED_CODE = 42 + + +class KwargOnlyError(Exception): + """Exception that only accepts a keyword-only argument and skips super().__init__.""" + + def __init__(self, *, message: str) -> None: + """Store the message without populating ``self.args`` via super().__init__.""" + self.message = message + + +class KwargGroup(ExceptionGroup): + """Exception group subclass that requires an extra keyword-only argument.""" + + code: int + + def __new__(cls, message: str, exceptions: list[Exception], *, code: int) -> Self: + """Attach ``code`` on the group created by ``BaseExceptionGroup.__new__``.""" + self = super().__new__(cls, message, exceptions) + self.code = code + return self + + def __init__(self, message: str, exceptions: list[Exception], *, code: int) -> None: + """Accept ``code`` so construction does not fall through to BaseException.""" + + +def test_exception_kwargs_arguments() -> None: + """ + A keyword-only exception should round-trip and reconstruct faithfully. + + Such an exception pickles but cannot be rebuilt by calling ``Cls(*self.args)`` + (its ``args`` is empty and ``message`` is required), so it exercises the + pickler that reconstructs exceptions via ``__new__``. + """ + + def raise_kwarg_error() -> Never: + raise KwargOnlyError(message="hello") + + try: + raise_kwarg_error() + except KwargOnlyError as e: + buffer = BytesIO() + save_traceback(e, buffer) + buffer.seek(0) + loaded_exc = load_traceback(buffer, should_raise=False) + + assert isinstance(loaded_exc, KwargOnlyError) + assert loaded_exc.message == "hello" + + +def test_exception_group_kwargs_arguments() -> None: + """ + An ExceptionGroup subclass with a required keyword-only arg should round-trip. + + Default pickling reduces a group to ``(Cls, (message, exceptions), state)`` and + rebuilds by calling ``Cls(message, exceptions)``, which fails when the subclass + requires an extra keyword-only argument. Reconstruction must go through + ``BaseExceptionGroup.__new__`` and restore the custom ``code`` state — without + requiring the subclass to override ``derive``. + """ + + def raise_kwarg_group() -> Never: + raise KwargGroup("grp", [ValueError("a"), KeyError("b")], code=EXPECTED_CODE) + + try: + raise_kwarg_group() + except KwargGroup as e: + buffer = BytesIO() + save_traceback(e, buffer) + buffer.seek(0) + loaded_exc = load_traceback(buffer, should_raise=False) + + assert isinstance(loaded_exc, KwargGroup) + assert loaded_exc.code == EXPECTED_CODE + assert loaded_exc.message == "grp" + assert [type(inner).__name__ for inner in loaded_exc.exceptions] == [ + "ValueError", + "KeyError", + ]