diff --git a/examples/adapters_demo.py b/examples/adapters_demo.py index effe6e3..56ada44 100644 --- a/examples/adapters_demo.py +++ b/examples/adapters_demo.py @@ -8,16 +8,16 @@ import threading from SmallPackage import SmallOS, SmallTask, Unix -from SmallPackage.adapters.asyncio_loop import AsyncioAdapter -from SmallPackage.adapters.errors import AdapterError -from SmallPackage.adapters.threads import ThreadAdapter from smallserver import ( + AdapterError, AdapterRegistry, + AsyncioAdapter, Headers, Request, Response, SmallServer, + ThreadAdapter, http_error_from_adapter, ) diff --git a/guide/adapters.md b/guide/adapters.md index 2bc91b1..0871eb9 100644 --- a/guide/adapters.md +++ b/guide/adapters.md @@ -1,8 +1,9 @@ # Third-party adapters SmallServer handlers run on the SmallOS scheduler and must not call blocking -functions or drive a second event loop directly. SmallOS supplies explicit -escape hatches: +functions or drive a second event loop directly. SmallServer exposes the +SmallOS-backed escape hatches through its own public API, so application code +does not need to import adapter modules from `SmallPackage`: - `ThreadAdapter` for blocking or thread-affine callables; - `AsyncioAdapter` for coroutine-based libraries on a persistent asyncio loop. @@ -11,9 +12,13 @@ The application creates, bounds, and shuts down these adapters. SmallServer does not create adapter workers as a side effect of `listen()`. ```python -from SmallPackage.adapters.errors import AdapterError -from SmallPackage.adapters.threads import ThreadAdapter -from smallserver import AdapterRegistry, Response, http_error_from_adapter +from smallserver import ( + AdapterError, + AdapterRegistry, + Response, + ThreadAdapter, + http_error_from_adapter, +) services = AdapterRegistry(blocking=ThreadAdapter(max_workers=2, max_pending=8)) @@ -39,6 +44,12 @@ and `shutdown()`. It rejects duplicate names and duplicate adapter objects, delegates calls, exposes stable registration order through `names()` and `items()`, and shuts adapters down in reverse registration order. +Import `ThreadAdapter` and `AsyncioAdapter` from `smallserver`. Their work is +still scheduled and completed through SmallOS, but the backend module layout is +not part of application code. SmallServer also exports `AdapterError` and its +capacity, unavailable, closed, cancelled, protocol, and execution subclasses +for explicit handling. + `http_error_from_adapter()` intentionally sanitizes adapter failures: capacity, unavailable, closed, and cancelled conditions become generic 503 responses; other adapter failures become a generic 500. Log internal causes in diff --git a/guide/api-reference.md b/guide/api-reference.md index eae0fd4..036fc6b 100644 --- a/guide/api-reference.md +++ b/guide/api-reference.md @@ -84,6 +84,14 @@ See [WebSockets](websockets.md) for handshake and completion semantics. ## Adapters +### `ThreadAdapter(max_workers=None, max_pending=64, ...)` + +Bounded worker-thread execution for synchronous or thread-affine callables. + +### `AsyncioAdapter(max_pending=64, ...)` + +Bounded coroutine execution on one persistent asyncio loop thread. + ### `AdapterRegistry(**adapters)` Methods: `register`, `get`, `call`, `names`, `items`, and `shutdown`. It also @@ -93,6 +101,11 @@ implements a context manager and exposes `closed`. Convert a SmallOS `AdapterError` to a sanitized `HTTPError`. +Applications can import `AdapterError`, `AdapterCapacityError`, +`AdapterUnavailableError`, `AdapterClosedError`, `AdapterCancelledError`, +`AdapterProtocolError`, and `AdapterExecutionError` directly from +`smallserver`. + ### `AdapterShutdownError` Raised after registry shutdown attempts every adapter but one or more fail. diff --git a/smallserver/__init__.py b/smallserver/__init__.py index 4fb8ae0..52f467b 100644 --- a/smallserver/__init__.py +++ b/smallserver/__init__.py @@ -31,12 +31,41 @@ ) if TYPE_CHECKING: - from .adapters import AdapterRegistry, AdapterShutdownError, http_error_from_adapter + from .adapters import ( + AdapterCancelledError, + AdapterCapacityError, + AdapterClosedError, + AdapterError, + AdapterExecutionError, + AdapterProtocolError, + AdapterRegistry, + AdapterShutdownError, + AdapterUnavailableError, + AsyncioAdapter, + ThreadAdapter, + http_error_from_adapter, + ) + + +_ADAPTER_EXPORTS = { + "AdapterCancelledError", + "AdapterCapacityError", + "AdapterClosedError", + "AdapterError", + "AdapterExecutionError", + "AdapterProtocolError", + "AdapterRegistry", + "AdapterShutdownError", + "AdapterUnavailableError", + "AsyncioAdapter", + "ThreadAdapter", + "http_error_from_adapter", +} def __getattr__(name: str) -> Any: """Load optional SmallOS adapter integration only when it is requested.""" - if name in {"AdapterRegistry", "AdapterShutdownError", "http_error_from_adapter"}: + if name in _ADAPTER_EXPORTS: from . import adapters value = getattr(adapters, name) @@ -45,8 +74,16 @@ def __getattr__(name: str) -> Any: raise AttributeError("module {!r} has no attribute {!r}".format(__name__, name)) __all__ = [ + "AdapterCancelledError", + "AdapterCapacityError", + "AdapterClosedError", + "AdapterError", + "AdapterExecutionError", + "AdapterProtocolError", "AdapterRegistry", "AdapterShutdownError", + "AdapterUnavailableError", + "AsyncioAdapter", "Headers", "HTTPError", "HTTP2Config", @@ -64,6 +101,7 @@ def __getattr__(name: str) -> Any: "ServerHandle", "ServerStartupError", "SmallServer", + "ThreadAdapter", "WebSocket", "WebSocketCapacityError", "WebSocketConfig", diff --git a/smallserver/adapters.py b/smallserver/adapters.py index c88f8a7..5107711 100644 --- a/smallserver/adapters.py +++ b/smallserver/adapters.py @@ -1,4 +1,4 @@ -"""Explicit lifecycle and HTTP translation helpers for SmallOS adapters.""" +"""SmallServer's public facade for SmallOS execution adapters.""" from __future__ import annotations @@ -6,17 +6,37 @@ from types import TracebackType from typing import Any +from SmallPackage.adapters.asyncio_loop import AsyncioAdapter from SmallPackage.adapters.errors import ( AdapterCancelledError, AdapterCapacityError, AdapterClosedError, AdapterError, + AdapterExecutionError, + AdapterProtocolError, AdapterUnavailableError, ) +from SmallPackage.adapters.threads import ThreadAdapter from .errors import HTTPError +__all__ = [ + "AdapterCancelledError", + "AdapterCapacityError", + "AdapterClosedError", + "AdapterError", + "AdapterExecutionError", + "AdapterProtocolError", + "AdapterRegistry", + "AdapterShutdownError", + "AdapterUnavailableError", + "AsyncioAdapter", + "ThreadAdapter", + "http_error_from_adapter", +] + + class AdapterShutdownError(RuntimeError): """One or more adapters failed while the registry was shutting down.""" diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 8ffb455..b9107d9 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -4,17 +4,20 @@ import unittest from SmallPackage import SmallOS, SmallTask, Unix -from SmallPackage.adapters.asyncio_loop import AsyncioAdapter -from SmallPackage.adapters.errors import AdapterCapacityError, AdapterProtocolError -from SmallPackage.adapters.threads import ThreadAdapter +import smallserver from smallserver import ( + AdapterCapacityError, + AdapterError, + AdapterProtocolError, AdapterRegistry, AdapterShutdownError, + AsyncioAdapter, Headers, Request, Response, SmallServer, + ThreadAdapter, http_error_from_adapter, ) @@ -36,6 +39,33 @@ def shutdown(self, wait=True, cancel_pending=False) -> None: class AdapterRegistryTests(unittest.TestCase): + def test_public_facade_exposes_adapter_types_and_errors(self) -> None: + expected_exports = { + "AdapterCancelledError", + "AdapterCapacityError", + "AdapterClosedError", + "AdapterError", + "AdapterExecutionError", + "AdapterProtocolError", + "AdapterUnavailableError", + "AsyncioAdapter", + "ThreadAdapter", + } + self.assertLessEqual(expected_exports, set(smallserver.__all__)) + for name in expected_exports: + self.assertIsNotNone(getattr(smallserver, name)) + self.assertIs(smallserver.ThreadAdapter, ThreadAdapter) + self.assertIs(smallserver.AsyncioAdapter, AsyncioAdapter) + blocking = ThreadAdapter(max_workers=1, max_pending=1) + foreign_async = AsyncioAdapter(max_pending=1) + try: + self.assertIsInstance(AdapterCapacityError("full"), AdapterError) + self.assertFalse(blocking.closed) + self.assertFalse(foreign_async.closed) + finally: + foreign_async.shutdown() + blocking.shutdown() + def test_registry_delegates_and_shuts_down_in_reverse_order(self) -> None: events: list[str] = [] first = FakeAdapter(events, "first") diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index 18441cf..3f78a17 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -7,10 +7,10 @@ import unittest from SmallPackage import SmallOS, Unix -from SmallPackage.adapters.threads import ThreadAdapter from smallserver import ( AdapterRegistry, + ThreadAdapter, RegexRouteConfig, Request, Response, diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 3edd8c0..d52e5b7 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -9,10 +9,10 @@ from unittest.mock import patch from SmallPackage import SmallOS, SmallTask, SmallWebSocketClient, Unix -from SmallPackage.adapters.threads import ThreadAdapter from smallserver import ( AdapterRegistry, + ThreadAdapter, Headers, Request, Response, diff --git a/tests/typing/adapters.py b/tests/typing/adapters.py new file mode 100644 index 0000000..2772f17 --- /dev/null +++ b/tests/typing/adapters.py @@ -0,0 +1,25 @@ +"""Public adapter-facade typing fixture.""" + +from smallserver import AdapterError, AdapterRegistry, AsyncioAdapter, ThreadAdapter + + +def blocking(value: str) -> int: + return len(value) + + +async def foreign_async(value: str) -> int: + return len(value) + + +def facade() -> AdapterRegistry: + thread_adapter = ThreadAdapter(max_workers=1, max_pending=4) + asyncio_adapter = AsyncioAdapter(max_pending=4) + services = AdapterRegistry(blocking=thread_adapter, async_sdk=asyncio_adapter) + + thread_adapter.call(blocking, "smallserver") + asyncio_adapter.call(foreign_async, "smallserver") + return services + + +def handle(error: AdapterError) -> str: + return str(error)