Implemented modular code structure
Refactored repository into kaya-core and kaya-rsgi packages
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# kaya-rsgi
|
||||
|
||||
RSGI/Granian integration for the Kaya web framework.
|
||||
|
||||
Provides `RsgiContext`, `RsgiWebSocket`, and `RsgiApplication` to run Kaya apps on Granian's RSGI protocol.
|
||||
@@ -0,0 +1,58 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "kaya-rsgi"
|
||||
dynamic = ["version"]
|
||||
authors = [
|
||||
{ name="Walter Oggioni", email="oggioni.walter@gmail.com" },
|
||||
]
|
||||
description = "RSGI/Granian integration for the Kaya web framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = "MIT"
|
||||
classifiers = [
|
||||
'Development Status :: 3 - Alpha',
|
||||
'Topic :: Utilities',
|
||||
'Intended Audience :: System Administrators',
|
||||
'Intended Audience :: Developers',
|
||||
'Environment :: Console',
|
||||
'Programming Language :: Python :: 3',
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"kaya-core",
|
||||
"granian>=2.0",
|
||||
"pwo",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"build", "mypy", "ipdb", "twine"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://github.com/woggioni/kaya"
|
||||
"Bug Tracker" = "https://github.com/woggioni/kaya/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
namespaces = true
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
disallow_untyped_defs = true
|
||||
show_error_codes = true
|
||||
no_implicit_optional = true
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
exclude = ["scripts", "docs", "test"]
|
||||
strict = true
|
||||
|
||||
[tool.setuptools_scm]
|
||||
root = "../.."
|
||||
version_file = "src/kaya/rsgi/_version.py"
|
||||
|
||||
[tool.setuptools_scm.tag]
|
||||
prefix = "release/"
|
||||
@@ -0,0 +1,11 @@
|
||||
from ._rsgi import RsgiApplication, RsgiContext, RsgiWebSocket
|
||||
from ._types import HTTPScope, WebSocketScope
|
||||
|
||||
|
||||
__all__ = [
|
||||
'RsgiApplication',
|
||||
'RsgiContext',
|
||||
'RsgiWebSocket',
|
||||
'HTTPScope',
|
||||
'WebSocketScope',
|
||||
]
|
||||
@@ -0,0 +1,200 @@
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
Sequence,
|
||||
Mapping,
|
||||
AsyncIterator,
|
||||
Tuple,
|
||||
AsyncGenerator,
|
||||
Optional,
|
||||
List,
|
||||
Dict,
|
||||
Callable,
|
||||
cast
|
||||
)
|
||||
|
||||
from granian._granian import ( # type: ignore[attr-defined]
|
||||
RSGIHTTPProtocol,
|
||||
RSGIHTTPScope,
|
||||
RSGIWebsocketProtocol,
|
||||
RSGIWebsocketScope,
|
||||
RSGIWebsocketTransport,
|
||||
)
|
||||
from pwo import Maybe
|
||||
|
||||
from kaya.core import AbstractKayaApp, HttpContext, HttpMethod, WebSocket, WebSocketMessage
|
||||
from kaya.core._types import StrOrStrings
|
||||
|
||||
|
||||
class RsgiContext(HttpContext):
|
||||
protocol: RSGIHTTPProtocol
|
||||
scheme: str
|
||||
method: HttpMethod
|
||||
path: str
|
||||
query_string: str
|
||||
headers: Mapping[str, Sequence[str]]
|
||||
client: Optional[Tuple[str, int]]
|
||||
server: Optional[Tuple[str, Optional[int]]]
|
||||
request_body: AsyncIterator[bytes]
|
||||
head = Optional[Tuple[int, Sequence[Tuple[str, str]]]]
|
||||
|
||||
def __init__(self, scope: RSGIHTTPScope, protocol: RSGIHTTPProtocol):
|
||||
self.scheme = scope.scheme
|
||||
self.path = scope.path
|
||||
self.method = HttpMethod(scope.method)
|
||||
self.query_string = scope.query_string
|
||||
|
||||
def acc(d: Dict[str, List[str]], t: Tuple[str, str]) -> Dict[str, List[str]]:
|
||||
d.setdefault(t[0].lower(), list()).append(t[1])
|
||||
return d
|
||||
|
||||
fun = cast(Callable[[Mapping[str, Sequence[str]], tuple[str, str]], Mapping[str, Sequence[str]]], acc)
|
||||
self.headers = reduce(fun, scope.headers.items(), {})
|
||||
self.client = (Maybe.of(scope.client.split(':'))
|
||||
.map(lambda it: (it[0], int(it[1])))
|
||||
.or_else_throw(RuntimeError))
|
||||
self.server = (Maybe.of(scope.server.split(':'))
|
||||
.map(lambda it: (it[0], int(it[1])))
|
||||
.or_else_throw(RuntimeError))
|
||||
self.request_body = cast(AsyncIterator[bytes], protocol)
|
||||
self.protocol = protocol
|
||||
|
||||
@staticmethod
|
||||
def _rearrange_headers(headers: Mapping[str, StrOrStrings]) -> List[Tuple[str, str]]:
|
||||
result = []
|
||||
for key, value in headers.items():
|
||||
if isinstance(value, str):
|
||||
result.append((key, value))
|
||||
elif isinstance(value, Sequence):
|
||||
for single_value in value:
|
||||
result.append((key, single_value))
|
||||
return result
|
||||
|
||||
async def stream_body(self,
|
||||
status: int,
|
||||
body_generator: AsyncGenerator[bytes, None],
|
||||
headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||
transport = self.protocol.response_stream(status,
|
||||
Maybe.of_nullable(headers)
|
||||
.map(self._rearrange_headers)
|
||||
.or_else([]))
|
||||
async for chunk in body_generator:
|
||||
await transport.send_bytes(chunk)
|
||||
|
||||
async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||
rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list())
|
||||
if len(body) > 0:
|
||||
self.protocol.response_bytes(status, rearranged_headers, body)
|
||||
else:
|
||||
self.protocol.response_empty(status, rearranged_headers)
|
||||
|
||||
async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||
rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list())
|
||||
if len(body) > 0:
|
||||
self.protocol.response_str(status, rearranged_headers, body)
|
||||
else:
|
||||
self.protocol.response_empty(status, rearranged_headers)
|
||||
|
||||
async def send_file(self, status: int, path: Path, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||
rearranged_headers = (Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers)
|
||||
.or_else(list()))
|
||||
self.protocol.response_file(status, rearranged_headers, str(path))
|
||||
|
||||
async def send_empty(self, status: int, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||
rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list())
|
||||
self.protocol.response_empty(status, rearranged_headers)
|
||||
|
||||
|
||||
class RsgiWebSocket(WebSocket):
|
||||
_protocol: RSGIWebsocketProtocol
|
||||
_transport: Optional[RSGIWebsocketTransport]
|
||||
scheme: str
|
||||
path: str
|
||||
query_string: str
|
||||
headers: Mapping[str, Sequence[str]]
|
||||
client: Optional[Tuple[str, int]]
|
||||
server: Optional[Tuple[str, Optional[int]]]
|
||||
|
||||
def __init__(self, scope: RSGIWebsocketScope, protocol: RSGIWebsocketProtocol):
|
||||
if not hasattr(protocol, 'accept') or not hasattr(protocol, 'close'):
|
||||
raise RuntimeError(
|
||||
'Granian was not configured for websockets; '
|
||||
'ensure Granian is started with websocket support enabled'
|
||||
)
|
||||
self._protocol = protocol
|
||||
self._transport = None
|
||||
self.scheme = scope.scheme
|
||||
self.path = scope.path
|
||||
self.query_string = scope.query_string
|
||||
|
||||
def acc(d: Dict[str, List[str]], t: Tuple[str, str]) -> Dict[str, List[str]]:
|
||||
d.setdefault(t[0].lower(), list()).append(t[1])
|
||||
return d
|
||||
|
||||
fun = cast(Callable[[Mapping[str, Sequence[str]], tuple[str, str]], Mapping[str, Sequence[str]]], acc)
|
||||
self.headers = reduce(fun, scope.headers.items(), {})
|
||||
self.client = (Maybe.of(scope.client.split(':'))
|
||||
.map(lambda it: (it[0], int(it[1])))
|
||||
.or_else_throw(RuntimeError))
|
||||
self.server = (Maybe.of(scope.server.split(':'))
|
||||
.map(lambda it: (it[0], int(it[1])))
|
||||
.or_else_throw(RuntimeError))
|
||||
|
||||
async def accept(self) -> None:
|
||||
self._transport = await self._protocol.accept()
|
||||
|
||||
async def receive(self) -> WebSocketMessage:
|
||||
if self._transport is None:
|
||||
raise RuntimeError('WebSocket connection has not been accepted yet')
|
||||
message = await self._transport.receive()
|
||||
if message.kind == 0:
|
||||
return WebSocketMessage(kind='close')
|
||||
elif message.kind == 1:
|
||||
return WebSocketMessage(kind='binary', data=message.data)
|
||||
elif message.kind == 2:
|
||||
return WebSocketMessage(kind='text', data=message.data)
|
||||
else:
|
||||
return WebSocketMessage(kind='close')
|
||||
|
||||
async def send_text(self, data: str) -> None:
|
||||
if self._transport is None:
|
||||
raise RuntimeError('WebSocket connection has not been accepted yet')
|
||||
await self._transport.send_str(data)
|
||||
|
||||
async def send_bytes(self, data: bytes) -> None:
|
||||
if self._transport is None:
|
||||
raise RuntimeError('WebSocket connection has not been accepted yet')
|
||||
await self._transport.send_bytes(data)
|
||||
|
||||
async def close(self, code: int = 1000) -> None:
|
||||
self._protocol.close(code)
|
||||
|
||||
async def __anext__(self) -> WebSocketMessage:
|
||||
message = await self.receive()
|
||||
if message.kind == 'close':
|
||||
raise StopAsyncIteration
|
||||
return message
|
||||
|
||||
|
||||
class RsgiApplication:
|
||||
_app: AbstractKayaApp
|
||||
|
||||
def __init__(self, app: AbstractKayaApp) -> None:
|
||||
self._app = app
|
||||
|
||||
def __rsgi_init__(self, loop: Any) -> None:
|
||||
self._app.setup(loop)
|
||||
|
||||
def __rsgi_del__(self, loop: Any) -> None:
|
||||
self._app.shutdown(loop)
|
||||
|
||||
async def __rsgi__(self,
|
||||
scope: RSGIHTTPScope | RSGIWebsocketScope,
|
||||
protocol: RSGIHTTPProtocol | RSGIWebsocketProtocol) -> None:
|
||||
if scope.proto == 'ws':
|
||||
ws = RsgiWebSocket(scope, protocol) # type: ignore[arg-type]
|
||||
await self._app.handle_websocket(ws)
|
||||
else:
|
||||
ctx = RsgiContext(scope, protocol) # type: ignore[arg-type]
|
||||
await self._app.handle_request(ctx)
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import (
|
||||
TypedDict,
|
||||
Literal,
|
||||
Optional,
|
||||
Mapping,
|
||||
)
|
||||
|
||||
|
||||
class HTTPScope(TypedDict):
|
||||
proto: Literal['http']
|
||||
rsgi_version: str
|
||||
http_version: str
|
||||
server: str
|
||||
client: str
|
||||
scheme: str
|
||||
method: str
|
||||
path: str
|
||||
query_string: str
|
||||
headers: Mapping[str, str]
|
||||
authority: Optional[str]
|
||||
|
||||
|
||||
class WebSocketScope(TypedDict):
|
||||
proto: Literal['ws']
|
||||
rsgi_version: str
|
||||
http_version: str
|
||||
server: str
|
||||
client: str
|
||||
scheme: str
|
||||
method: str
|
||||
path: str
|
||||
query_string: str
|
||||
headers: Mapping[str, str]
|
||||
authority: Optional[str]
|
||||
|
||||
|
||||
__all__ = [
|
||||
'HTTPScope',
|
||||
'WebSocketScope',
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
import unittest
|
||||
from kaya.rsgi import RsgiWebSocket
|
||||
|
||||
|
||||
class RsgiWebSocketTest(unittest.TestCase):
|
||||
|
||||
def test_misconfigured_granian(self):
|
||||
class FakeScope:
|
||||
scheme = 'ws'
|
||||
path = '/ws'
|
||||
query_string = ''
|
||||
headers = {}
|
||||
client = '127.0.0.1:12345'
|
||||
server = '127.0.0.1:80'
|
||||
|
||||
class FakeProtocol:
|
||||
pass
|
||||
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
RsgiWebSocket(FakeScope(), FakeProtocol()) # type: ignore[arg-type]
|
||||
|
||||
self.assertIn('Granian was not configured for websockets', str(ctx.exception))
|
||||
Reference in New Issue
Block a user