Implemented modular code structure

Refactored repository into kaya-core and kaya-rsgi packages
This commit is contained in:
2026-07-15 22:18:45 +08:00
parent eca6da3dc3
commit fa3ff32f66
33 changed files with 238 additions and 387 deletions
+5
View File
@@ -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.
+58
View File
@@ -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',
]
+200
View File
@@ -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',
]
+22
View File
@@ -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))