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
+192
View File
@@ -0,0 +1,192 @@
import unittest
import json
import httpx
from pwo import async_test
from kaya.core import KayaApp, HttpContext, HttpMethod
from typing import Sequence, List
class AsgiTest(unittest.TestCase):
app: KayaApp
def setUp(self):
self.app = KayaApp()
@self.app.GET('/hello')
@self.app.GET('/hello2')
@self.app.route('/hello3')
@self.app.GET('/hello/*')
async def handle_request(ctx: HttpContext) -> None:
async for chunk in ctx.request_body:
print(chunk)
await ctx.send_str(200, 'Hello World!')
@self.app.route(('/foo/bar',), HttpMethod.PUT, recursive=True)
async def handle_request(ctx: HttpContext) -> None:
async for chunk in ctx.request_body:
print(chunk)
await ctx.send_str(200, ctx.path)
@self.app.route(('/foo/*',), HttpMethod.PUT, recursive=True)
async def handle_request(ctx: HttpContext, path: Sequence[str]) -> None:
async for chunk in ctx.request_body:
print(chunk)
await ctx.send_str(200, json.dumps(path))
@self.app.route(('/foo/*',), recursive=True)
async def handle_request(ctx: HttpContext, path: Sequence[str]) -> None:
await ctx.send_str(500, f"Unable to handle request for {ctx.path}")
@self.app.GET('/employee/${employee_id}')
async def handle_request(ctx: HttpContext, employee_id: str) -> None:
async for chunk in ctx.request_body:
print(chunk)
await ctx.send_str(200, employee_id)
@self.app.GET('/square/${x:int}')
async def handle_request(ctx: HttpContext, x: int) -> None:
async for chunk in ctx.request_body:
print(chunk)
await ctx.send_str(200, str(x * x))
@self.app.GET('/department/${department_id:int}/employee/${employee_id:int}')
async def handle_request(ctx: HttpContext, department_id: int, employee_id: int) -> None:
async for chunk in ctx.request_body:
print(chunk)
await ctx.send_str(200, json.dumps({
'department_id': department_id,
'employee_id': employee_id
}))
@self.app.PUT('/hello/*', recursive=True)
async def handle_request(ctx: HttpContext, _: List[str]) -> None:
await ctx.stream_body(200, (chunk async for chunk in ctx.request_body))
@async_test
async def test_hello(self):
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
r = await client.get("/hello")
self.assertEqual(200, r.status_code)
self.assertEqual(r.text, "Hello World!")
r = await client.get("/hello2")
self.assertEqual(200, r.status_code)
self.assertEqual(r.text, "Hello World!")
r = await client.post("/hello3")
self.assertEqual(200, r.status_code)
self.assertEqual(r.text, "Hello World!")
r = await client.get("/hello4")
self.assertEqual(404, r.status_code)
self.assertTrue(len(r.text) == 0)
body = {'name': 'John', 'surname': 'Smith'}
r = await client.put("/hello/foo/bar", json=body)
self.assertEqual(200, r.status_code)
ans = json.loads(r.text)
self.assertTrue(body, ans)
@async_test
async def test_foo(self):
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
r = await client.put("/foo/fizz/baz")
self.assertEqual(200, r.status_code)
response = json.loads(r.text)
self.assertEqual(['fizz', 'baz'], response)
r = await client.get("/foo/not-put")
self.assertEqual(500, r.status_code)
self.assertEqual("Unable to handle request for /foo/not-put", r.text)
@async_test
async def test_method_agnostic_fallback_order_independence(self):
app = KayaApp()
@app.route(('/foo/*',), recursive=True)
async def handle_request(ctx: HttpContext, path: Sequence[str]) -> None:
await ctx.send_str(500, f"Unable to handle request for {ctx.path}")
@app.route(('/foo/*',), HttpMethod.PUT, recursive=True)
async def handle_request(ctx: HttpContext, path: Sequence[str]) -> None:
await ctx.send_str(200, json.dumps(path))
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
r = await client.put("/foo/fizz/baz")
self.assertEqual(200, r.status_code)
self.assertEqual(['fizz', 'baz'], json.loads(r.text))
r = await client.get("/foo/not-put")
self.assertEqual(500, r.status_code)
self.assertEqual("Unable to handle request for /foo/not-put", r.text)
@async_test
async def test_disjoint_method_specific_matchers(self):
app = KayaApp()
@app.route(('/foo/*',), HttpMethod.PUT, recursive=True)
async def handle_request(ctx: HttpContext, path: Sequence[str]) -> None:
await ctx.send_str(200, "PUT")
@app.route(('/foo/*',), HttpMethod.GET, recursive=True)
async def handle_request(ctx: HttpContext, path: Sequence[str]) -> None:
await ctx.send_str(200, "GET")
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
r = await client.put("/foo/bar")
self.assertEqual(200, r.status_code)
self.assertEqual("PUT", r.text)
r = await client.get("/foo/bar")
self.assertEqual(200, r.status_code)
self.assertEqual("GET", r.text)
@async_test
async def test_foo_bar(self):
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
r = await client.put("/foo/bar/baz")
self.assertEqual(200, r.status_code)
self.assertEqual('/foo/bar/baz', r.text)
@async_test
async def test_employee(self):
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
r = await client.get("/employee/101325")
self.assertEqual(200, r.status_code)
self.assertEqual(r.text, '101325')
@async_test
async def test_square(self):
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
x = 30
r = await client.get(f"/square/{x}")
self.assertEqual(200, r.status_code)
self.assertEqual(r.text, str(x * x))
@async_test
async def test_department_employee(self):
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
r = await client.get("department/189350/employee/101325")
self.assertEqual(200, r.status_code)
response = json.loads(r.text)
self.assertEqual({
'department_id': 189350,
'employee_id': 101325
}, response)
+103
View File
@@ -0,0 +1,103 @@
from typing import Sequence, Tuple, Optional, List
from kaya.core import Tree, PathHandler, HttpContext, HttpMethod, PathIterator
from pwo import Maybe
import unittest
class PathIteratorTest(unittest.TestCase):
cases: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
('/', tuple()),
('root/foo', ('root', 'foo')),
('/root', ('root',)),
('/root', ('root',)),
('/root/', ('root',)),
('/root/bar/', ('root', 'bar')),
)
def test_path_iterator(self):
for (case, expected) in self.cases:
with self.subTest(case) as _:
components = tuple((c for c in PathIterator(case)))
self.assertEqual(expected, components)
class TreeTest(unittest.TestCase):
tree: Tree
handlers: List[PathHandler]
def setUp(self):
self.tree = Tree()
class TestHandler(PathHandler):
def handle_request(self, ctx: HttpContext):
pass
@property
def recursive(self) -> bool:
return True
self.handlers = [TestHandler() for _ in range(20)]
routes: Tuple[Tuple[Tuple[str, ...], Optional[HttpMethod], PathHandler], ...] = (
(('home', 'something'), HttpMethod.GET, self.handlers[0]),
(('home', 'something_else'), HttpMethod.GET, self.handlers[1]),
(('home', 'something_else'), HttpMethod.POST, self.handlers[2]),
(('home', 'something', 'object'), HttpMethod.GET, self.handlers[3]),
(('home', 'something_else', 'foo'), HttpMethod.GET, self.handlers[4]),
(('home',), HttpMethod.GET, self.handlers[5]),
(('home',), HttpMethod.POST, self.handlers[6]),
(('home',), None, self.handlers[7]),
(('home', '*.md'), None, self.handlers[8]),
(('home', 'something', '*', 'blah', '*.md'), None, self.handlers[9]),
(('home', 'bar', '*'), None, self.handlers[10]),
)
for path, method, handler in routes:
self.tree.add((p for p in path), method, handler)
def test_tree(self):
cases: Tuple[Tuple[str, HttpMethod, Optional[int]], ...] = (
('http://localhost:127.0.0.1:5432/home/something', HttpMethod.GET, 0),
('http://localhost:127.0.0.1:5432/home/something_else', HttpMethod.GET, 1),
('http://localhost:127.0.0.1:5432/home/something_else', HttpMethod.POST, 2),
('http://localhost:127.0.0.1:5432/home/something/object', HttpMethod.GET, 3),
('http://localhost:127.0.0.1:5432/home/something_else/foo', HttpMethod.GET, 4),
('http://localhost:127.0.0.1:5432/', HttpMethod.GET, None),
('http://localhost:127.0.0.1:5432/home', HttpMethod.GET, 5),
('http://localhost:127.0.0.1:5432/home', HttpMethod.POST, 6),
('http://localhost:127.0.0.1:5432/home', HttpMethod.PUT, 7),
('http://localhost:127.0.0.1:5432/home/README.md', HttpMethod.GET, 8),
('http://localhost:127.0.0.1:5432/home/something/ciao/blah/README.md', HttpMethod.GET, 9),
('http://localhost:127.0.0.1:5432/home/bar/ciao/blah/README.md', HttpMethod.GET, 10),
)
for url, method, handler_num in cases:
with self.subTest(f"{str(method)} {url}"):
res = self.tree.get_handler(url, method)
self.assertIs(Maybe.of(handler_num).map(self.handlers.__getitem__).or_none(),
Maybe.of_nullable(res).map(lambda it: it[0]).or_none())
def test_two_method_agnostic_matchers_raise(self):
tree = Tree()
tree.add((p for p in ('foo', '*')), None, self.handlers[0])
with self.assertRaises(ValueError):
tree.add((p for p in ('foo', '*')), None, self.handlers[1])
def test_two_overlapping_method_specific_matchers_raise(self):
tree = Tree()
tree.add((p for p in ('foo', '${id:int}')), HttpMethod.PUT, self.handlers[0])
with self.assertRaises(ValueError):
tree.add((p for p in ('foo', '${name:str}')), HttpMethod.PUT, self.handlers[1])
def test_disjoint_method_specific_matchers_allowed(self):
tree = Tree()
tree.add((p for p in ('foo', '*')), HttpMethod.PUT, self.handlers[0])
tree.add((p for p in ('foo', '*')), HttpMethod.GET, self.handlers[1])
put_handler = Maybe.of_nullable(tree.get_handler('/foo/bar', HttpMethod.PUT)).map(lambda it: it[0]).or_none()
get_handler = Maybe.of_nullable(tree.get_handler('/foo/bar', HttpMethod.GET)).map(lambda it: it[0]).or_none()
self.assertIs(self.handlers[0], put_handler)
self.assertIs(self.handlers[1], get_handler)
+153
View File
@@ -0,0 +1,153 @@
import unittest
from typing import Any, Callable, Awaitable, List, Mapping, Optional
from pwo import async_test
from kaya.core import KayaApp, WebSocket, WebSocketMessage
def websocket_scope(path: str = '/ws') -> Mapping[str, Any]:
return {
'type': 'websocket',
'asgi': {'spec_version': '2.3', 'version': '3.0'},
'http_version': '1.1',
'scheme': 'ws',
'path': path,
'raw_path': path.encode(),
'query_string': b'',
'root_path': '',
'headers': [],
'client': ('127.0.0.1', 12345),
'server': ('127.0.0.1', 80),
'subprotocols': [],
'extensions': None,
}
class WebSocketTest(unittest.TestCase):
app: KayaApp
def setUp(self):
self.app = KayaApp()
@self.app.websocket('/echo')
async def echo(ws: WebSocket) -> None:
await ws.accept()
async for msg in ws:
if msg.kind == 'text':
await ws.send_text(f"echo: {msg.data}")
elif msg.kind == 'binary':
data = msg.data
assert isinstance(data, bytes)
await ws.send_bytes(data)
@self.app.websocket('/room/${room_id}')
async def room(ws: WebSocket, room_id: str) -> None:
await ws.accept()
async for msg in ws:
if msg.kind == 'text':
await ws.send_text(f"[{room_id}] {msg.data}")
@async_test
async def test_echo_text(self):
sent_messages: List[Mapping[str, Any]] = []
received_messages: List[Mapping[str, Any]] = []
async def receive() -> Mapping[str, Any]:
if not received_messages:
received_messages.append({'type': 'websocket.connect'})
return received_messages[-1]
if len(received_messages) == 1:
received_messages.append({'type': 'websocket.receive', 'text': 'hello'})
return received_messages[-1]
received_messages.append({'type': 'websocket.disconnect', 'code': 1000})
return received_messages[-1]
async def send(message: Mapping[str, Any]) -> None:
sent_messages.append(message)
await self.app(websocket_scope('/echo'), receive, send)
self.assertEqual(sent_messages[0]['type'], 'websocket.accept')
self.assertEqual(sent_messages[1]['type'], 'websocket.send')
self.assertEqual(sent_messages[1]['text'], 'echo: hello')
@async_test
async def test_echo_binary(self):
sent_messages: List[Mapping[str, Any]] = []
received_messages: List[Mapping[str, Any]] = []
async def receive() -> Mapping[str, Any]:
if not received_messages:
received_messages.append({'type': 'websocket.connect'})
return received_messages[-1]
if len(received_messages) == 1:
received_messages.append({'type': 'websocket.receive', 'bytes': b'hello'})
return received_messages[-1]
received_messages.append({'type': 'websocket.disconnect', 'code': 1000})
return received_messages[-1]
async def send(message: Mapping[str, Any]) -> None:
sent_messages.append(message)
await self.app(websocket_scope('/echo'), receive, send)
self.assertEqual(sent_messages[0]['type'], 'websocket.accept')
self.assertEqual(sent_messages[1]['type'], 'websocket.send')
self.assertEqual(sent_messages[1]['bytes'], b'hello')
@async_test
async def test_path_parameter(self):
sent_messages: List[Mapping[str, Any]] = []
received_messages: List[Mapping[str, Any]] = []
async def receive() -> Mapping[str, Any]:
if not received_messages:
received_messages.append({'type': 'websocket.connect'})
return received_messages[-1]
if len(received_messages) == 1:
received_messages.append({'type': 'websocket.receive', 'text': 'hi'})
return received_messages[-1]
received_messages.append({'type': 'websocket.disconnect', 'code': 1000})
return received_messages[-1]
async def send(message: Mapping[str, Any]) -> None:
sent_messages.append(message)
await self.app(websocket_scope('/room/general'), receive, send)
self.assertEqual(sent_messages[0]['type'], 'websocket.accept')
self.assertEqual(sent_messages[1]['text'], '[general] hi')
@async_test
async def test_no_handler(self):
sent_messages: List[Mapping[str, Any]] = []
async def receive() -> Mapping[str, Any]:
return {'type': 'websocket.connect'}
async def send(message: Mapping[str, Any]) -> None:
sent_messages.append(message)
await self.app(websocket_scope('/unknown'), receive, send)
self.assertEqual(sent_messages[0]['type'], 'websocket.close')
self.assertEqual(sent_messages[0]['code'], 1000)
@async_test
async def test_close_from_client(self):
sent_messages: List[Mapping[str, Any]] = []
received_messages: List[Mapping[str, Any]] = []
async def receive() -> Mapping[str, Any]:
if not received_messages:
received_messages.append({'type': 'websocket.connect'})
return received_messages[-1]
received_messages.append({'type': 'websocket.disconnect', 'code': 1001})
return received_messages[-1]
async def send(message: Mapping[str, Any]) -> None:
sent_messages.append(message)
await self.app(websocket_scope('/echo'), receive, send)
self.assertEqual(sent_messages[0]['type'], 'websocket.accept')
self.assertEqual(len(sent_messages), 1)