Use Dependency Injection in Lihil
masterdeps=[...]) or within WebSocket channels using self.resolver.aresolve(...).repository·master·Indexed 42 days ago
https://github.com/raceychan/lihilA high-performance, strictly typed async Python web framework for enterprise-scale applications and AI-driven workloads. It features a high-performance dependency injection system, a managed WebSocket hub (SocketHub), Server-Sent Events (SSE) support, and a plugin system based on the IPlugin protocol. Lihil supports parameter validation via msgspec, Pydantic, dataclasses, and TypedDict, and implements RFC 9457 Problem Details for structured error handling via HTTPException.
deps=[...]) or within WebSocket channels using self.resolver.aresolve(...).Lihil automatically parses and validates request parameters (query strings, path params, headers, cookies, and bodies) against type hints.
Supported Models:
msgspec.Struct (Default and fastest)pydantic.BaseModel (First-class support, no plugin required)dataclasses.dataclasstyping.TypedDictLihil uses msgspec by default for high-performance JSON serialization/deserialization.
The Socket Channel protocol uses a standardized MessageEnvelope to facilitate request-response pairing and event tracking between clients and the server.
When a client sends a message, it should include:
topic: The target topic string.event: The event name (e.g., join, leave, send_message).payload: The event data.ref: A client-generated request ID used to pair the request with a subsequent server response.join_ref: An ID used to distinguish the lifecycle of a specific topic subscription on the same socket.When the server sends a message, it includes:
topic: The target topic.event: The server-side event name.payload: The event data.ref: If this is a response to a request, this must contain the original client ref.join_ref: The topic lifecycle reference.event_id: A unique server-side event ID (useful for replay/recovery).seq: A monotonic sequence number for the connection (useful for ordering and debugging).// Example Inbound Envelope
{
"topic": "session:abc",
"event": "join",
"payload": {},
"ref": "1",
"join_ref": null
}
// Example Outbound Envelope
{
"topic": "session:abc",
"event": "reply",
"payload": {
"status": "ok",
"response": {}
},
"ref": "1",
"join_ref": "1",
"event_id": "evt_123",
"seq": 12
}The SocketHub is a high-level WebSocket feature using class-based channels.
Key Concepts:
ChannelBase and declare a topic using Topic("pattern:{param}").async def on_join(self, room_id: str). Lihil injects these and validates them.await self.publish(payload, event="event_name") to broadcast messages to all subscribers of a topic.self.start_task(name, coro) for long-running work. These tasks are automatically cancelled on disconnect.self.resolver.aresolve(...).hub.channel(MyChannel) and mount the hub to the application using app = Lihil(hub).Protocol Envelope: Communication uses a stable envelope containing ref (request reference), join_ref (for connection-local pairing), and seq (sequence) fields to allow clients to pair requests with replies.
Lihil uses a plugin system that allows you to integrate external libraries by wrapping endpoint functionality. Plugins implement the IPlugin protocol and can access full endpoint context, including signatures and dependency graphs.
Plugins are applied during setup (left to right) and execute at runtime using a nested/onion pattern. When multiple plugins are provided in the plugins list, the first plugin in the list becomes the outermost layer of the onion, and the last plugin becomes the innermost layer.
Execution Order:
Request $\rightarrow$ Plugin[0] $\rightarrow$ Plugin[1] $\dots$ $\rightarrow$ Original Function $\rightarrow$ Plugin[n] $\dots$ $\rightarrow$ Plugin[0] $\rightarrow$ Response
Example Flow:
If you define an endpoint with plugins=[plugin.timeout(5), plugin.retry(max_attempts=3), plugin.cache(expire_s=60)]:
timeout (Outermost)retry (Middle)cache (Innermost)endpoint functioncache $\rightarrow$ retry $\rightarrow$ timeout $\rightarrow$ Response@app.sub("/api").get(plugins=[
plugin.timeout(5), # Applied 1st → Executes Outermost
plugin.retry(max_attempts=3), # Applied 2nd → Executes Middle
plugin.cache(expire_s=60), # Applied 3rd → Executes Innermost
])SocketHub is a high-level routing class designed for managed, channel-based WebSocket communication. Unlike the low-level WebSocketRoute, SocketHub does not require a user-supplied handler function; instead, it uses an internal managed loop to dispatch messages based on defined channels and hooks.
Key features include:
@on_connect and @on_disconnect to manage connection lifecycles..channel(pattern) and handle events via .on_join, .on_exit, and .on_receive.hub.include(subhub, parent_prefix=...).Note: SocketHub is intended to replace the older managed features in WebSocketRoute to provide a cleaner separation between low-level socket handling and high-level channel management.
class SocketHub(RouteBase):
def __init__(self, path: str = "", *, graph: Graph | None = None, middlewares=None):
# ...
def channel(self, pattern: str) -> Channel:
# ...
def on_connect(self, func: IAsyncFunc[..., None]) -> IAsyncFunc[..., None]:
# ...
def on_disconnect(self, func: IAsyncFunc[..., None]) -> IAsyncFunc[..., None]:
# ...To implement managed WebSocket logic, use SocketHub to register connection hooks and channel patterns. Channels allow you to match incoming message topics to specific logic.
on_connect(func): Decorator to register a function called when a new WebSocket connects.on_disconnect(func): Decorator to register a function called when a WebSocket disconnects.channel(pattern): Registers a new Channel object. The pattern can include placeholders (e.g., "user/{id}"). Channels are matched by complexity (more placeholders take precedence).When a client sends a message with an event of "join", the channel's on_join callback is triggered. When the client sends "leave", on_exit is triggered.
hub = SocketHub(path="/ws")
@hub.on_connect
async def handle_connect(sock):
print(f"Connected: {sock.id}")
chat_channel = hub.channel("chat/{room_id}")
@chat_channel.on_join
async def handle_join(params, sock):
await sock.send_json({"event": "welcome", "payload": params})Lihil requires that you keep HTTP Route trees and WebSocketRoute trees separate. You can merge routes of the same type using .sub() or .merge(), but you must not attempt to merge an HTTP Route into a WebSocketRoute or vice versa. Pass both top-level trees to the Lihil application instance.
api = Route("api")
v1 = api.sub("v1")
users = v1.sub("users")
ws = WebSocketRoute("ws")
ws_v1 = ws.sub("v1")
ws_notify = ws_v1.sub("notification")
app = Lihil(api, ws) # Correct: passing both top-level routesLihil requires Python 3.10 or higher. To install the standard version which includes uvicorn, use the following command:
pip install "lihil[standard]"To create a custom plugin, implement a class that provides a decorate method. This method receives an IEndpointInfo object, which provides access to the original function, its parsed signature, and the dependency injection graph. The decorate method must return a callable that wraps the original function.
An IPlugin can be either a callable or a class with a decorate method.
from lihil.plugins.interface import IPlugin, IEndpointInfo
from lihil.interface import IAsyncFunc, P, R
from typing import Callable, Awaitable
class MyCustomPlugin:
def __init__(self, external_service):
self.service = external_service
def decorate(self, ep_info: IEndpointInfo[P, R]) -> Callable[P, Awaitable[R]]:
original_func = ep_info.func
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# Pre-processing
await self.service.before_request(ep_info.sig)
try:
result = await original_func(*args, **kwargs)
# Post-processing
return await self.service.process_result(result)
except Exception as e:
await self.service.handle_error(e)
raise
return wrapper
# Usage
plugin = MyCustomPlugin(ExternalService())
@app.sub("/api/data").get(plugins=[plugin.decorate])
async def get_data() -> dict:
return {"data": "value"}The LocalClient is a specialized client designed to test Lihil endpoints, routes, middleware, and full ASGI applications in a local environment. It can simulate HTTP requests by interacting directly with the ASGI interface, allowing you to verify application logic without a running network server.
Key capabilities include:
Endpoint objects.Route objects.ASGIApp instances (including lifespan events).submit_form.from lihil.local_client import LocalClient
client = LocalClient()
# Use the client to call an app or endpoint
result = await client(app, method="GET", path="/test")The Lihil class is the main entrypoint for the framework. You can initialize it with routes, middlewares, and an optional lifespan. To run the application using the built-in Uvicorn runner, use the .run() method, passing the current file path.
from lihil import Lihil
app = Lihil()
# Define routes on the root...
if __name__ == "__main__":
app.run(__file__)