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
6 changes: 3 additions & 3 deletions examples/adapters_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
21 changes: 16 additions & 5 deletions guide/adapters.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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))

Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions guide/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
42 changes: 40 additions & 2 deletions smallserver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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",
Expand All @@ -64,6 +101,7 @@ def __getattr__(name: str) -> Any:
"ServerHandle",
"ServerStartupError",
"SmallServer",
"ThreadAdapter",
"WebSocket",
"WebSocketCapacityError",
"WebSocketConfig",
Expand Down
22 changes: 21 additions & 1 deletion smallserver/adapters.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,42 @@
"""Explicit lifecycle and HTTP translation helpers for SmallOS adapters."""
"""SmallServer's public facade for SmallOS execution adapters."""

from __future__ import annotations

from collections.abc import Callable, Iterator
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."""

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

Expand All @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_server_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion tests/test_websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions tests/typing/adapters.py
Original file line number Diff line number Diff line change
@@ -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)
Loading