diff --git a/docs/reference/errors.md b/docs/reference/errors.md index dc47314..3048989 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -1,5 +1,73 @@ # Errors +Every fallible wirelog C entry point returns a `wirelog_error_t` integer code. +The [`check()`][pyrewire._core.errors.check] helper maps any non-OK code to the +matching typed Python exception, so callers can write +`except CompoundBusyError:` instead of inspecting raw integers. +PyreWire adds three loader/session/intern-level errors — +[`WirelogVersionError`][pyrewire._core.errors.WirelogVersionError], +[`WirelogModeError`][pyrewire._core.errors.WirelogModeError], and +[`WirelogInternError`][pyrewire._core.errors.WirelogInternError] — +that have no C counterpart and are never produced by `check()`. + +## Error-code mapping + +| `wirelog_error_t` | Code | `error_string` text | Exception class | +|---|---|---|---| +| `WIRELOG_OK` | 0 | ok | — (`check()` returns `None`) | +| `WIRELOG_ERR_PARSE` | 1 | parse error | [`ParseError`][pyrewire._core.errors.ParseError] | +| `WIRELOG_ERR_INVALID_IR` | 2 | invalid IR | [`InvalidIRError`][pyrewire._core.errors.InvalidIRError] | +| `WIRELOG_ERR_EXEC` | 3 | execution error | [`ExecError`][pyrewire._core.errors.ExecError] | +| `WIRELOG_ERR_MEMORY` | 4 | out of memory | [`WirelogMemoryError`][pyrewire._core.errors.WirelogMemoryError] | +| `WIRELOG_ERR_IO` | 5 | I/O error | [`WirelogIOError`][pyrewire._core.errors.WirelogIOError] | +| `WIRELOG_ERR_COMPOUND_SATURATED` | 6 | compound arena saturated | [`CompoundSaturatedError`][pyrewire._core.errors.CompoundSaturatedError] | +| `WIRELOG_ERR_COMPOUND_BUSY` | 7 | compound arena busy | [`CompoundBusyError`][pyrewire._core.errors.CompoundBusyError] | +| (any other non-zero) | 255 | unknown error | [`WirelogError`][pyrewire._core.errors.WirelogError] (base) | + +Codes with no specific class (including `WIRELOG_ERR_UNKNOWN`/255) raise the +base [`WirelogError`][pyrewire._core.errors.WirelogError] via `check()`'s +fallback. + +### PyreWire-only errors (not produced by `check()`) + +- [`WirelogVersionError`][pyrewire._core.errors.WirelogVersionError] — raised + when the loaded libwirelog is missing an optional capability or symbol + required by a PyreWire API. +- [`WirelogModeError`][pyrewire._core.errors.WirelogModeError] — raised by + session classes when an operation is attempted in the wrong session mode + (step/snapshot/query); the session must be closed and reopened to switch + modes. +- [`WirelogInternError`][pyrewire._core.errors.WirelogInternError] — raised + when a symbol-id reverse lookup fails. + +## Exception handling + +```python +from pyrewire import ParseError, Program +from pyrewire import CompoundBusyError # see docs/semantics/compounds.md for retry loop + +try: + prog = Program.from_file("rules.wl") +except ParseError as exc: + # line and column are populated for file-based parsing + loc = f"{exc.source}:{exc.line}:{exc.column}" if exc.line else str(exc.source) + print(f"Parse failed at {loc}: {exc}") +``` + +[`CompoundBusyError`][pyrewire._core.errors.CompoundBusyError] is a transient arena-contention error that should be retried with backoff — see the [retry loop in the Compounds semantics guide](../semantics/compounds.md#retry-loop-for-compoundbusyerror). + +## API reference + +`error_string()` prefers the live `wirelog_error_string` symbol when available +and falls back to a PyreWire-side text table on older builds, so its output may +differ slightly from the table above (e.g. `OK` vs `ok`). + +::: pyrewire._core.errors.check + +::: pyrewire._core.errors.error_string + +## Exception classes + ::: pyrewire._core.errors.WirelogError ::: pyrewire._core.errors.ParseError diff --git a/src/pyrewire/_core/errors.py b/src/pyrewire/_core/errors.py index cdbb977..a446df8 100644 --- a/src/pyrewire/_core/errors.py +++ b/src/pyrewire/_core/errors.py @@ -5,11 +5,16 @@ typed Python exception so high-level callers can do `except CompoundBusyError: retry()` without inspecting integer codes. -Two exception classes have no wirelog counterpart: - -- `WirelogVersionError` is raised by the loader on a version mismatch. -- `WirelogModeError` is raised by session classes when `step()` and - `snapshot()` are mixed inside the same insert batch. +Three exception classes have no wirelog counterpart: + +- `WirelogVersionError` is raised when the loaded libwirelog is missing + an optional capability/symbol required by a PyreWire API (e.g. + ``Program.relation_ir`` requires ``wirelog_program_get_relation_ir``, + added in wirelog#860 / > 0.41.0). It is NOT produced by ``check()``. +- `WirelogModeError` is raised by session classes when an operation is + attempted in the wrong session mode (step/snapshot/query); the session + must be closed and reopened to switch modes. It is NOT produced by + ``check()``. - `WirelogInternError` is raised when a reverse-intern lookup fails. `error_string(rc)` prefers `LIB.wirelog_error_string` (exported since @@ -34,7 +39,21 @@ class WirelogError(Exception): class ParseError(WirelogError): - """`WIRELOG_ERR_PARSE`. Carries `line`/`column`/`source` when available.""" + """Raised when the wirelog parser rejects a program (``WIRELOG_ERR_PARSE``, code 1). + + The optional attributes ``line``, ``column``, and ``source`` are + populated on a best-effort basis. They are usually ``None`` unless the + error was raised from file parsing via ``Program.from_file``; string + parsing via ``Program.from_string`` does not populate them. + + Attributes: + line: 1-based line number where the parse error occurred, or + ``None`` if unavailable. + column: 1-based column number where the parse error occurred, or + ``None`` if unavailable. + source: Path to the source file being parsed, or ``None`` if + unavailable. + """ code = int(ErrorCode.PARSE) @@ -53,28 +72,96 @@ def __init__( class InvalidIRError(WirelogError): + """Raised when the program's IR fails structural validation (``WIRELOG_ERR_INVALID_IR``, + code 2). + + Produced by ``check()`` when the C engine rejects a compiled IR before + execution begins. Typically indicates a bug in rule compilation or a + corrupted program handle. + """ + code = int(ErrorCode.INVALID_IR) class ExecError(WirelogError): + """Catch-all error for runtime failures in the wirelog engine (``WIRELOG_ERR_EXEC``, + code 3). + + This is the broadest error class in PyreWire. Beyond engine execution + failures it is also raised directly (without going through ``check()``) + for a variety of precondition violations: + + - **Invalid arguments** — e.g. an unknown or undeclared relation name + (``session.py``, ``program.py``). + - **Closed or null handles** — operations attempted on a closed + ``BatchProgram`` or ``Result``, or a NULL handle returned by the C + library (``batch.py``, ``session.py``). + - **Missing / unknown relations** — schema lookups that find no entry + for the requested relation (``program.py``, ``session.py``). + - **Wirelog call failures** — when the C call returns a non-zero code + that is not mapped to a more specific subclass. + + When catching engine errors broadly, catch ``ExecError``; for finer + control catch the more specific subclasses (``ParseError``, + ``InvalidIRError``, etc.) first. + """ + code = int(ErrorCode.EXEC) class WirelogMemoryError(WirelogError): - """`WIRELOG_ERR_MEMORY`. NOT a subclass of built-in `MemoryError`.""" + """Raised when the wirelog engine cannot allocate memory (``WIRELOG_ERR_MEMORY``, + code 4). + + This class is intentionally **not** a subclass of the built-in + ``MemoryError``. Subclassing ``MemoryError`` can interact badly with + the CPython interpreter's low-memory handling and recursion-limit + machinery, producing hard-to-diagnose crashes. Callers that need to + catch both should list them explicitly: + ``except (WirelogMemoryError, MemoryError)``. + """ code = int(ErrorCode.MEMORY) class WirelogIOError(WirelogError): + """Raised when an I/O adapter registry operation fails (``WIRELOG_ERR_IO``, + code 5). + + Raised directly (without going through ``check()``) by the I/O adapter + registry in ``io_adapter.py`` when ``wirelog_io_register_adapter`` or + ``wirelog_io_unregister_adapter`` returns a non-zero code. It is also + reachable via ``check()`` if the C engine returns code 5 for other I/O + failures. + """ + code = int(ErrorCode.IO) class CompoundSaturatedError(WirelogError): + """Raised when the compound arena's epoch counter is exhausted + (``WIRELOG_ERR_COMPOUND_SATURATED``, code 6). + + Corresponds to an ``ENOSPC``-style condition: the arena has no more + epoch slots available and cannot accept new compounds. This error is + **not retryable** — the session must be closed and reopened to obtain a + fresh arena. See ``docs/semantics/compounds.md`` for the epoch + lifetime model. + """ + code = int(ErrorCode.COMPOUND_SATURATED) class CompoundBusyError(WirelogError): + """Raised when another worker holds the compound arena epoch + (``WIRELOG_ERR_COMPOUND_BUSY``, code 7). + + Corresponds to an ``EBUSY``-style condition: the arena epoch is + currently owned by a concurrent writer. This error is **transient** — + callers should retry the operation with an appropriate backoff strategy + rather than propagating or aborting. + """ + code = int(ErrorCode.COMPOUND_BUSY) @@ -82,15 +169,43 @@ class CompoundBusyError(WirelogError): class WirelogVersionError(WirelogError): - """libwirelog reports a version different from `pyrewire.__version__`.""" + """Raised when the loaded libwirelog is missing an optional capability + required by a PyreWire API. + + This exception has no wirelog C counterpart and is never produced by + ``check()``. It acts as a capability/symbol gate: if libwirelog does not + export a symbol that a PyreWire API requires, that API raises this error + rather than crashing. For example, ``Program.relation_ir`` requires + ``wirelog_program_get_relation_ir`` (added in wirelog#860, > 0.41.0) and + raises ``WirelogVersionError`` against older libwirelog builds that lack + it. + + Note: the loader's minimum-version check (``_ffi/_loader.py``) raises a + plain ``Exception`` subclass (``_loader.WirelogVersionError``), not this + class. + """ class WirelogModeError(WirelogError): - """A session call violates the `step()` / `snapshot()` exclusivity rule.""" + """Raised by session classes when an operation is rejected due to session + mode exclusivity. + + This exception has no wirelog C counterpart and is never produced by + ``check()``. A session commits to a single mode (step, snapshot, or + query) on its first mode-specific operation. Any subsequent call that + requires a different mode raises this error. To switch modes, close the + current session and open a new one. + """ class WirelogInternError(WirelogError): - """A symbol id could not be reverse-mapped to its string value.""" + """Raised when a symbol id cannot be reverse-mapped to its string value. + + This exception has no wirelog C counterpart and is never produced by + ``check()``. It is raised by the intern table when a numeric symbol id + is looked up but has no corresponding string entry, indicating an + internal consistency error. + """ # --- Error-code mapping ----------------------------------------------------- diff --git a/tests/test_errors.py b/tests/test_errors.py index a0386ad..166cc37 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -2,8 +2,11 @@ from __future__ import annotations +import inspect + import pytest +import pyrewire._core.errors as _errors_module from pyrewire._core.errors import ( _FALLBACK_TEXT, CompoundBusyError, @@ -103,3 +106,33 @@ def test_check_uses_error_string_text(): check(int(ErrorCode.PARSE)) except ParseError as exc: assert str(exc) == error_string(int(ErrorCode.PARSE)) + + +def _public_wirelog_error_classes() -> list[type]: + """Return every WirelogError subclass defined in the errors module.""" + results = [] + for _name, obj in inspect.getmembers(_errors_module, inspect.isclass): + if issubclass(obj, WirelogError) and obj.__module__ == _errors_module.__name__: + results.append(obj) + return results + + +@pytest.mark.parametrize("cls", _public_wirelog_error_classes()) +def test_exception_class_has_nontrivial_docstring(cls: type) -> None: + """Every WirelogError subclass defined in the errors module must have its + own non-trivial docstring (stripped length >= 30 chars). + + The test is self-discovering: it enumerates classes whose + ``__module__`` matches the errors module, so future additions are + covered automatically without updating this test. The threshold of + 30 characters is chosen to reject placeholder stubs (e.g. ``"..."`` or + a bare one-liner like ``"An error."``) while accepting any real + sentence. + """ + _MIN_DOC_LEN = 30 + doc = cls.__dict__.get("__doc__") + assert doc is not None, f"{cls.__name__} is missing its own docstring" + assert len(doc.strip()) >= _MIN_DOC_LEN, ( + f"{cls.__name__} docstring is too short " + f"({len(doc.strip())} < {_MIN_DOC_LEN} chars): {doc!r}" + )