From 754a9c420939c8e5f8c62df9ba33457ed08988e6 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Thu, 2 Jul 2026 22:26:05 +0400 Subject: [PATCH] release(0.10.0): add nullrun.handle / guarded / init_or_die Three zero-boilerplate helpers in src/nullrun/_handle.py that translate any NullRunError into format_user_message(exc) on stderr + sys.exit(1): * nullrun.handle() - context manager * @nullrun.guarded - decorator * nullrun.init_or_die() - wraps init() so NR-C001 'no api_key' exits cleanly instead of traceback All three propagate WorkflowKilledInterrupt (BaseException) unchanged and let non-NullRun exceptions surface as honest tracebacks. The new symbols are added to _LAZY_EXPORTS and __all__ in src/nullrun/__init__.py. Module name is _handle.py (underscore prefix) so it does not collide with the public nullrun.handle context manager under pytest's test discovery import path. Bumps: * pyproject.toml: 0.9.0 -> 0.10.0 * src/nullrun/__version__.py: 0.8.0 -> 0.10.0 Tests: tests/test_handle.py covers handle / guarded / init_or_die including the WorkflowKilledInterrupt bypass, non-NullRun passthrough, and custom exit_code override. --- pyproject.toml | 2 +- src/nullrun/__init__.py | 28 +++++ src/nullrun/__version__.py | 2 +- src/nullrun/_handle.py | 193 ++++++++++++++++++++++++++++++ tests/test_handle.py | 235 +++++++++++++++++++++++++++++++++++++ 5 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 src/nullrun/_handle.py create mode 100644 tests/test_handle.py diff --git a/pyproject.toml b/pyproject.toml index c6da484..e408d94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "nullrun" -version = "0.9.0" +version = "0.10.0" # Long form used by PyPI page meta-description and search snippets. # Kept under the 200-char preview threshold so the full line is visible # without an "expand" click. Keywords are matched against likely search diff --git a/src/nullrun/__init__.py b/src/nullrun/__init__.py index a16c542..b154f36 100644 --- a/src/nullrun/__init__.py +++ b/src/nullrun/__init__.py @@ -442,6 +442,22 @@ def my_agent(): "format_user_message": ("nullrun.messages", "format_user_message"), "set_user_message": ("nullrun.messages", "set_user_message"), "get_user_message": ("nullrun.messages", "get_user_message"), + # Minimal-boilerplate error handling for scripts (see + # nullrun/_handle.py for the rationale). Pair with @nullrun.protect + # so a typical ``run an agent and print a friendly message on + # failure`` script needs no explicit try/except around + # NullRunError. WorkflowKilledInterrupt (BaseException) still + # propagates — kill is never swallowed. + # + # The module is named ``_handle.py`` (private, leading underscore) + # so it does not collide with the public ``nullrun.handle`` + # context manager. With a non-underscored name, pytest's test + # discovery would pre-import ``nullrun.handle`` as a submodule, + # which shadows the lazy export and breaks ``from nullrun import + # handle``. + "handle": ("nullrun._handle", "handle"), + "guarded": ("nullrun._handle", "guarded"), + "init_or_die": ("nullrun._handle", "init_or_die"), } @@ -523,6 +539,18 @@ def __dir__() -> list[str]: # own wording per error_code without rewriting the SDK. "format_user_message", "set_user_message", + # Minimal-boilerplate error handling for scripts. ``handle`` is + # the context manager (``with nullrun.handle():``), ``guarded`` + # is the decorator (``@nullrun.guarded``). Both translate any + # ``NullRunError`` into ``print(format_user_message(exc))`` + + # ``sys.exit(1)``; ``WorkflowKilledInterrupt`` propagates. + # ``init_or_die`` is the convenience wrapper around ``init`` + # that catches NR-C001 "no api_key" at startup and exits + # cleanly — without it the user sees a raw traceback before + # any ``with handle():`` block is in scope. + "handle", + "guarded", + "init_or_die", ] # Sprint 2.1: the SDK-side ``decision_history`` module was deleted. diff --git a/src/nullrun/__version__.py b/src/nullrun/__version__.py index 3b1e122..a0ede71 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -1,4 +1,4 @@ """NullRun Platform SDK.""" -__version__ = "0.8.0" +__version__ = "0.10.0" __platform_version__ = "1.0.0" diff --git a/src/nullrun/_handle.py b/src/nullrun/_handle.py new file mode 100644 index 0000000..33bd5cb --- /dev/null +++ b/src/nullrun/_handle.py @@ -0,0 +1,193 @@ +""" +Minimal-boilerplate error handling for the NullRun SDK. + +The SDK exposes structured exceptions (``NullRunError`` + ~12 +specialized subclasses) and a user-message catalog +(:func:`nullrun.format_user_message`). Knowing every class by name is +the maximum-information path — useful for integrators who want to +branch on a specific ``error_code`` — but it is **not** the default. + +For the common "I just want to run my agent and print a friendly +message on failure" case, this module provides three one-liners: + +* :func:`nullrun.handle` — context manager. +* :func:`nullrun.guarded` — decorator. +* :func:`nullrun.init_or_die` — convenience wrapper around + :func:`nullrun.init` that catches the ``NR-C001`` "no api_key" + failure at startup and exits cleanly. + +All three translate any :class:`nullrun.NullRunError` into a single +``print(format_user_message(exc), file=sys.stderr)`` followed by +``sys.exit(1)``. :class:`nullrun.WorkflowKilledInterrupt` is a +``BaseException`` subclass and therefore propagates through all three +— the kill signal is never silently swallowed. Non-NullRun exceptions +also propagate unchanged. + +``init_or_die`` exists because :func:`nullrun.init` is typically +called at module top-level — before any ``with handle():`` block or +``@guarded`` decorator is in scope. Without it, a missing +``NULLRUN_API_KEY`` env var produces a raw traceback. + +Why a separate module +--------------------- +The exception hierarchy in :mod:`nullrun.breaker.exceptions` is the +mechanism — every raise site uses it. This module is the *policy* +default: "scripts that just want a friendly exit code". It belongs +in user-facing code, not in the breaker, because it depends on +``sys.exit`` and the user-message catalog — neither of which the +breaker module imports. + +Why ``_handle.py`` (leading underscore) +--------------------------------------- +The public symbol exported from this module is :func:`handle` (a +context manager). With a non-underscored module name +``nullrun/handle.py``, Python's import machinery pre-binds +``nullrun.handle`` to the submodule when anything does +``import nullrun.handle`` (for example, pytest's test discovery). +That binding shadows the lazy export ``"handle": (...)`` in +:mod:`nullrun`, so ``from nullrun import handle`` returns the +module object instead of the function. The leading underscore +makes the module private so it does not collide. +""" +from __future__ import annotations + +import sys +from collections.abc import Callable +from contextlib import contextmanager +from typing import TypeVar + +from nullrun.breaker.exceptions import NullRunError +from nullrun.messages import format_user_message + +T = TypeVar("T") + + +@contextmanager +def handle(*, exit_code: int = 1): + """Catch ``NullRunError`` and translate it to a user-facing exit. + + Inside the ``with`` block, any :class:`nullrun.NullRunError` is + caught, its catalog user-message is printed to stderr, and the + process exits with ``exit_code``. The base :class:`nullrun.NullRunError` + carries ``error_code`` / ``user_action`` / ``retryable`` / ``docs_url`` + — but those are operator-facing; for the end user we use the + friendly wording from :func:`nullrun.format_user_message`. + + Exceptions that propagate unchanged: + + * :class:`nullrun.WorkflowKilledInterrupt` (``BaseException``) — kill + signals must reach the top of the agent loop, not be swallowed + into a graceful exit. + * :class:`KeyboardInterrupt` / :class:`SystemExit` (``BaseException``) — + same reason as the kill signal. + * Any non-NullRun exception — the user's own bugs are not handled + here; let them propagate for an honest traceback. + + Args: + exit_code: Process exit status to use after a caught error. + Defaults to ``1``. + + Example:: + + import nullrun + + nullrun.init(api_key="nr_live_...") + + with nullrun.handle(): + run_my_agent("hello") + # ↑ if run_my_agent raised NullRunError, the catalog + # user-message is printed and the script exits 1. + """ + try: + yield + except NullRunError as exc: + print(format_user_message(exc), file=sys.stderr) + sys.exit(exit_code) + + +def guarded(fn: Callable[..., T]) -> Callable[..., T]: + """Decorator equivalent of ``with nullrun.handle():``. + + Wrap a function so any :class:`nullrun.NullRunError` raised inside + it is caught, rendered as a user-facing message, and the process + exits with code ``1``. ``WorkflowKilledInterrupt`` and other + ``BaseException`` subclasses propagate. + + Pair with :func:`nullrun.protect` for the standard agent loop:: + + @nullrun.guarded + @nullrun.protect + def my_agent(prompt): + return call_llm(prompt) + + if __name__ == "__main__": + try: + print(my_agent("hello")) + finally: + nullrun.shutdown() + + Args: + fn: The function to wrap. + + Returns: + A wrapper with the same signature that exits the process on + ``NullRunError`` and otherwise returns ``fn``'s value. + """ + def wrapper(*args, **kwargs): + with handle(): + return fn(*args, **kwargs) + + return wrapper + + +def init_or_die(*, api_key: str | None = None, api_url: str | None = None, + debug: bool = False, exit_code: int = 1): + """Call :func:`nullrun.init` and exit cleanly on configuration failure. + + :func:`nullrun.init` is typically the first thing a script does, + before any ``with nullrun.handle():`` block or ``@nullrun.guarded`` + decorator is in scope. A missing ``api_key`` therefore produces a + raw traceback — not a friendly exit. ``init_or_die`` closes that + gap by catching the startup :class:`nullrun.NullRunError` (NR-C001 + "no api_key"), printing the catalog user-message, and exiting. + + On success returns the :class:`nullrun.NullRunRuntime` singleton + that ``init()`` returns — assign it if you need it, ignore it + otherwise:: + + from nullrun import init_or_die, guarded, protect, shutdown + + init_or_die(api_key=os.environ["NULLRUN_API_KEY"]) + + @guarded + @protect + def my_agent(prompt): + return call_llm(prompt) + + if __name__ == "__main__": + try: + print(my_agent("hello")) + finally: + shutdown() + + Args: + api_key: NullRun API key (or NULLRUN_API_KEY env var). + api_url: Gateway URL (or NULLRUN_API_URL env var). + debug: Enable debug logging on the runtime. + exit_code: Process exit status to use when init fails. + + Returns: + The runtime singleton returned by ``init()``. + """ + # Lazy import — ``init`` pulls in the runtime + transport stack. + # Skipping that when init is never called keeps the import path + # of ``from nullrun import init_or_die`` light. + from nullrun import init + try: + return init(api_key=api_key, api_url=api_url, debug=debug) + except NullRunError as exc: + print(format_user_message(exc), file=sys.stderr) + sys.exit(exit_code) + + +__all__ = ["handle", "guarded", "init_or_die"] \ No newline at end of file diff --git a/tests/test_handle.py b/tests/test_handle.py new file mode 100644 index 0000000..53b28a4 --- /dev/null +++ b/tests/test_handle.py @@ -0,0 +1,235 @@ +"""Tests for the minimal-boilerplate error helpers (``nullrun.handle``, +``nullrun.guarded``). + +Contract: + +* Both translate any :class:`nullrun.NullRunError` into a single + ``print(format_user_message(exc), file=sys.stderr)`` and then + ``sys.exit(1)``. +* :class:`nullrun.WorkflowKilledInterrupt` (BaseException) propagates + unchanged — kill must not be swallowed into a graceful exit. +* Non-NullRun exceptions also propagate unchanged so the user's own + bugs surface as honest tracebacks. +* No runtime is required — these helpers work without + ``nullrun.init()``. +""" +from __future__ import annotations + +import pytest + +import nullrun +from nullrun import guarded, handle +from nullrun.breaker.exceptions import ( + NullRunBudgetError, + NullRunError, + WorkflowKilledInterrupt, +) + + +def test_handle_catches_nullrun_error_and_exits(monkeypatch, capsys): + """``with handle():`` exits 1 and prints the catalog user-message.""" + exits = [] + + def fake_exit(code): + exits.append(code) + raise SystemExit(code) + + monkeypatch.setattr("sys.exit", fake_exit) + + with pytest.raises(SystemExit): + with handle(): + # NullRunBudgetError inherits from NullRunBlockedException, + # whose __init__ takes (workflow_id, reason, ...). + raise NullRunBudgetError("wf-1", "workflow budget exhausted") + + captured = capsys.readouterr() + assert "limit" in captured.err.lower() or "budget" in captured.err.lower() + assert exits == [1] + + +def test_handle_propagates_workflow_killed(monkeypatch): + """``WorkflowKilledInterrupt`` is BaseException — must NOT be caught.""" + monkeypatch.setattr("sys.exit", lambda c: pytest.fail("sys.exit was called")) + + with pytest.raises(WorkflowKilledInterrupt): + with handle(): + raise WorkflowKilledInterrupt("wf-1", "killed via dashboard") + + +def test_handle_propagates_value_error(monkeypatch): + """Non-NullRun exceptions pass through for an honest traceback.""" + monkeypatch.setattr("sys.exit", lambda c: pytest.fail("sys.exit was called")) + + with pytest.raises(ValueError): + with handle(): + raise ValueError("user bug, not an SDK failure") + + +def test_handle_returns_on_success(monkeypatch): + """A clean ``with`` block returns the wrapped expression's value.""" + monkeypatch.setattr("sys.exit", lambda c: pytest.fail("sys.exit was called")) + + with handle(): + result = 1 + 2 + + assert result == 3 + + +def test_guarded_decorator_catches_and_exits(monkeypatch, capsys): + """``@guarded`` translates NullRunError into sys.exit(1).""" + exits = [] + + def fake_exit(code): + exits.append(code) + raise SystemExit(code) + + monkeypatch.setattr("sys.exit", fake_exit) + + @guarded + def boom(): + raise NullRunError("something broke", error_code="NR-B002") + + with pytest.raises(SystemExit): + boom() + + assert exits == [1] + captured = capsys.readouterr() + # NR-B002 maps to the "service is temporarily unavailable" wording. + assert "temporarily unavailable" in captured.err.lower() + + +def test_guarded_returns_value_on_success(monkeypatch): + """``@guarded`` returns the wrapped function's value when nothing fails.""" + monkeypatch.setattr("sys.exit", lambda c: pytest.fail("sys.exit was called")) + + @guarded + def add(a, b): + return a + b + + assert add(2, 3) == 5 + + +def test_guarded_propagates_workflow_killed(monkeypatch): + """The kill signal still propagates through the decorator.""" + monkeypatch.setattr("sys.exit", lambda c: pytest.fail("sys.exit was called")) + + @guarded + def boom(): + raise WorkflowKilledInterrupt("wf-7", "killed via API") + + with pytest.raises(WorkflowKilledInterrupt): + boom() + + +def test_handle_exit_code_kwarg(monkeypatch, capsys): + """``handle(exit_code=42)`` honours the override.""" + exits = [] + + def fake_exit(code): + exits.append(code) + raise SystemExit(code) + + monkeypatch.setattr("sys.exit", fake_exit) + + with pytest.raises(SystemExit): + with handle(exit_code=42): + raise NullRunError("oops", error_code="NR-B002") + + assert exits == [42] + + +def test_no_init_required(): + """``handle`` / ``guarded`` must not depend on a runtime.""" + # If handle pulled in the runtime, importing this module would have + # raised during the prior tests. Smoke-test the import path here. + assert callable(handle) + assert callable(guarded) + assert callable(nullrun.handle) + assert callable(nullrun.guarded) + assert callable(nullrun.init_or_die) + + +# --------------------------------------------------------------------------- +# init_or_die +# --------------------------------------------------------------------------- + +class _FakeNoopRuntime: + """Sentinel returned by a stubbed init(). init_or_die should pass + it through unchanged.""" + + +def test_init_or_die_returns_runtime(monkeypatch): + """On success, ``init_or_die`` returns whatever ``init()`` returned.""" + sentinel = _FakeNoopRuntime() + + def fake_init(**kwargs): + assert kwargs["api_key"] == "nr_live_test" + return sentinel + + monkeypatch.setattr("nullrun.init", fake_init) + monkeypatch.setattr("sys.exit", lambda c: pytest.fail("sys.exit was called")) + + result = nullrun.init_or_die(api_key="nr_live_test") + assert result is sentinel + + +def test_init_or_die_catches_missing_api_key(monkeypatch, capsys): + """NR-C001 from init() → catalog user-message + sys.exit(1).""" + from nullrun.breaker.exceptions import NullRunAuthenticationError + + def fake_init(**kwargs): + raise NullRunAuthenticationError( + "nullrun.init() requires an api_key.", + error_code="NR-C001", + user_action="Get an API key at https://app.nullrun.io/settings/api-keys", + ) + + exits = [] + + def fake_exit(code): + exits.append(code) + raise SystemExit(code) + + monkeypatch.setattr("nullrun.init", fake_init) + monkeypatch.setattr("sys.exit", fake_exit) + + with pytest.raises(SystemExit): + nullrun.init_or_die(api_key=None) + + captured = capsys.readouterr() + assert "configuration issue" in captured.err.lower() + assert exits == [1] + + +def test_init_or_die_propagates_unexpected(monkeypatch): + """Non-NullRun exceptions from init() propagate — not handled.""" + def fake_init(**kwargs): + raise ValueError("totally unrelated bug") + + monkeypatch.setattr("nullrun.init", fake_init) + monkeypatch.setattr("sys.exit", lambda c: pytest.fail("sys.exit was called")) + + with pytest.raises(ValueError): + nullrun.init_or_die(api_key="nr_live_test") + + +def test_init_or_die_exit_code_kwarg(monkeypatch, capsys): + """``init_or_die(exit_code=42)`` honours the override.""" + from nullrun.breaker.exceptions import NullRunAuthenticationError + + def fake_init(**kwargs): + raise NullRunAuthenticationError("no key", error_code="NR-C001") + + exits = [] + + def fake_exit(code): + exits.append(code) + raise SystemExit(code) + + monkeypatch.setattr("nullrun.init", fake_init) + monkeypatch.setattr("sys.exit", fake_exit) + + with pytest.raises(SystemExit): + nullrun.init_or_die(api_key=None, exit_code=42) + + assert exits == [42] \ No newline at end of file