Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/reference/errors.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
135 changes: 125 additions & 10 deletions src/pyrewire/_core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -53,44 +72,140 @@ 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)


# --- PyreWire-only exception types -----------------------------------------


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 -----------------------------------------------------
Expand Down
33 changes: 33 additions & 0 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}"
)