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
+8 -5
View File
@@ -26,14 +26,17 @@ jobs:
.venv/bin/pip install -r requirements-dev.txt
- name: Unit tests
run: |
.venv/bin/pip install .
.venv/bin/python -m mypy -p src
.venv/bin/python -m unittest discover -s tests
.venv/bin/pip install packages/kaya-core packages/kaya-rsgi
.venv/bin/python -m mypy -p kaya.core
.venv/bin/python -m mypy -p kaya.rsgi
.venv/bin/python -m unittest discover -s packages/kaya-core/tests
.venv/bin/python -m unittest discover -s packages/kaya-rsgi/tests
- name: Publish artifacts
env:
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
TWINE_USERNAME: ${{ vars.PUBLISHER_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PUBLISHER_TOKEN }}
run: |
.venv/bin/pyproject-build
.venv/bin/twine upload --repository gitea dist/*.whl dist/*.tar.gz
.venv/bin/pyproject-build packages/kaya-core
.venv/bin/pyproject-build packages/kaya-rsgi
.venv/bin/twine upload --repository gitea packages/kaya-core/dist/*.whl packages/kaya-core/dist/*.tar.gz packages/kaya-rsgi/dist/*.whl packages/kaya-rsgi/dist/*.tar.gz
+2 -2
View File
@@ -4,5 +4,5 @@ __pycache__
.mypy_cache
_version.py
*.egg-info
/build
/dist
build/
dist/
+23 -5
View File
@@ -1,6 +1,15 @@
# kaya
A lightweight ASGI/RSGI web framework with method-aware routing, path matching, and recursive wildcard support.
A lightweight ASGI web framework with method-aware routing, path matching, and recursive wildcard support.
## Packages
This repository is a monorepo for the Kaya framework. The code is split into independent packages under `packages/`:
- **kaya-core** — core routing, HTTP/WS abstractions, and ASGI adapter (`packages/kaya-core/`)
- **kaya-rsgi** — RSGI/Granian integration (`packages/kaya-rsgi/`)
Additional `kaya-*` packages can be added as new directories under `packages/`.
## Build & run locally
@@ -10,10 +19,10 @@ Install dev dependencies:
pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra-index-url https://pypi.org/simple -r requirements-dev.txt
```
Build the package:
Install the packages in development mode:
```bash
python -m build
pip install -e packages/kaya-core -e packages/kaya-rsgi
```
Run the example:
@@ -25,11 +34,20 @@ python example/hello.py
## Tests
```bash
PYTHONPATH=src python -m unittest discover tests
python -m unittest discover -s packages/kaya-core/tests
python -m unittest discover -s packages/kaya-rsgi/tests
```
## Static analysis
```bash
mypy src/kaya
mypy -p kaya.core
mypy -p kaya.rsgi
```
## Building packages
```bash
python -m build packages/kaya-core
python -m build packages/kaya-rsgi
```
+2 -11
View File
@@ -1,15 +1,6 @@
from kaya import KayaApp, HttpContext, HttpMethod, WebSocket
from kaya.core import KayaApp, HttpContext, HttpMethod, WebSocket
from typing import List
class Hello(KayaApp):
pass
# async def handle_request(self, ctx: HttpContext) -> None:
# async for chunk in ctx.request_body:
# print(chunk.decode())
# await ctx.send_str(200, 'Hello World')
app = KayaApp()
@@ -53,4 +44,4 @@ async def echo(ws: WebSocket) -> None:
elif msg.kind == 'binary':
data = msg.data
assert isinstance(data, bytes)
await ws.send_bytes(data)
await ws.send_bytes(data)
+5
View File
@@ -0,0 +1,5 @@
# kaya-core
Core package of the Kaya web framework.
Provides method-aware routing, path matching, HTTP/WebSocket abstractions, and an ASGI adapter.
+57
View File
@@ -0,0 +1,57 @@
[build-system]
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
[project]
name = "kaya-core"
dynamic = ["version"]
authors = [
{ name="Walter Oggioni", email="oggioni.walter@gmail.com" },
]
description = "Core package of the Kaya lightweight ASGI 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 = [
"pwo",
"typing-extensions",
]
[project.optional-dependencies]
dev = [
"build", "mypy", "ipdb", "twine", "httpx"
]
[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/core/_version.py"
[tool.setuptools_scm.tag]
prefix = "release/"
@@ -1,17 +1,19 @@
from ._app import KayaApp
from ._app import AbstractKayaApp, KayaApp
from ._http_method import HttpMethod
from ._http_context import HttpContext
from ._tree import Tree, PathIterator
from ._path_handler import PathHandler
from ._path_handler import PathHandler, Matches
from ._websocket import WebSocket, WebSocketMessage
__all__ = [
'AbstractKayaApp',
'HttpMethod',
'KayaApp',
'HttpContext',
'Tree',
'PathHandler',
'Matches',
'PathIterator',
'WebSocket',
'WebSocketMessage'
@@ -2,31 +2,13 @@ from abc import ABC, abstractmethod
from asyncio import Queue, AbstractEventLoop
from asyncio import get_running_loop
from logging import getLogger
from typing import Callable, Awaitable, Any, Mapping, Sequence, Optional, Unpack, Tuple, TYPE_CHECKING, cast
from pathlib import Path, PurePath
from typing import Callable, Awaitable, Any, Mapping, Sequence, Optional, Unpack, Tuple, cast
from pwo import Maybe, AsyncQueueIterator
from hashlib import md5
from ._http_context import HttpContext
from ._http_method import HttpMethod
from ._path_handler import Context
from ._types import StrOrStrings
from ._websocket import WebSocket
from base64 import b64encode, b64decode
from mimetypes import guess_type
if TYPE_CHECKING:
from _typeshed import StrOrBytesPath
try:
from ._rsgi import RsgiContext, RsgiWebSocket
from granian._granian import ( # type: ignore
RSGIHTTPProtocol,
RSGIHTTPScope,
RSGIWebsocketProtocol,
RSGIWebsocketScope as RSGIWebSocketScope,
)
except ImportError:
pass
from ._asgi import AsgiContext, AsgiWebSocket
from ._tree import Tree
from ._types.asgi import LifespanScope, HTTPScope as ASGIHTTPScope, WebSocketScope as ASGIWebSocketScope
@@ -87,20 +69,6 @@ class AbstractKayaApp(ABC):
async def handle_websocket(self, ws: WebSocket) -> None:
raise NotImplementedError()
def __rsgi_init__(self, loop: AbstractEventLoop) -> None:
self.setup(loop)
def __rsgi_del__(self, loop: AbstractEventLoop) -> None:
self.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.handle_websocket(ws)
else:
ctx = RsgiContext(scope, protocol) # type: ignore[arg-type]
await self.handle_request(ctx)
class KayaApp(AbstractKayaApp):
_tree: Tree
@@ -115,7 +83,6 @@ class KayaApp(AbstractKayaApp):
await handler.handle_request(ctx, captured)
else:
await ctx.send_empty(404)
pass
async def handle_websocket(self, ws: WebSocket) -> None:
result = self._tree.get_handler(ws.path, HttpMethod.WS)
@@ -124,7 +91,6 @@ class KayaApp(AbstractKayaApp):
await handler.handle_request(ws, captured)
else:
await ws.close(1000)
pass
def route(self,
paths: StrOrStrings,
@@ -179,4 +145,3 @@ class KayaApp(AbstractKayaApp):
def PATCH(self, path: str, recursive: bool = False) -> Callable[[HttpHandler], HttpHandler]:
return self.route(path, (HttpMethod.PATCH,), recursive)
@@ -0,0 +1,31 @@
from typing import (
TypedDict,
Literal,
Iterable,
Tuple,
Optional,
NotRequired,
Dict,
Any,
Union,
Mapping,
Sequence
)
from .base import StrOrStrings, PathMatcherResult
from .asgi import ASGIVersions, HTTPScope, WebSocketScope, LifespanScope
from .._http_method import HttpMethod
type NodeType = (str | HttpMethod)
__all__ = [
'HttpMethod',
'HTTPScope',
'LifespanScope',
'ASGIVersions',
'WebSocketScope',
'NodeType',
'StrOrStrings',
'PathMatcherResult'
]
@@ -2,7 +2,7 @@ import unittest
import json
import httpx
from pwo import async_test
from kaya import KayaApp, HttpContext, HttpMethod
from kaya.core import KayaApp, HttpContext, HttpMethod
from typing import Sequence, List
@@ -1,7 +1,6 @@
from typing import Sequence, Tuple, Optional, List
from kaya import Tree, PathHandler, HttpContext, HttpMethod, PathIterator
from kaya import HttpMethod
from kaya.core import Tree, PathHandler, HttpContext, HttpMethod, PathIterator
from pwo import Maybe
import unittest
@@ -1,7 +1,7 @@
import unittest
from typing import Any, Callable, Awaitable, List, Mapping, Optional
from pwo import async_test
from kaya import KayaApp, WebSocket, WebSocketMessage
from kaya.core import KayaApp, WebSocket, WebSocketMessage
def websocket_scope(path: str = '/ws') -> Mapping[str, Any]:
@@ -151,25 +151,3 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual(sent_messages[0]['type'], 'websocket.accept')
self.assertEqual(len(sent_messages), 1)
class RsgiWebSocketTest(unittest.TestCase):
def test_misconfigured_granian(self):
from kaya._rsgi import RsgiWebSocket
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))
+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.
@@ -3,42 +3,43 @@ requires = ["setuptools>=61.0", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
[project]
name = "kaya"
name = "kaya-rsgi"
dynamic = ["version"]
authors = [
{ name="Walter Oggioni", email="oggioni.walter@gmail.com" },
]
description = "A lightweight ASGI/RSGI web framework"
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',
'License :: OSI Approved :: MIT License',
'Intended Audience :: System Administrators',
'Intended Audience :: Developers',
'Environment :: Console',
'License :: OSI Approved :: MIT License',
'Programming Language :: Python :: 3',
]
dependencies = [
"kaya-core",
"granian>=2.0",
"pwo",
]
[project.optional-dependencies]
dev = [
"build", "mypy", "ipdb", "twine", "granian", "httpx"
]
rsgi = [
"granian"
"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
@@ -50,7 +51,8 @@ exclude = ["scripts", "docs", "test"]
strict = true
[tool.setuptools_scm]
version_file = "src/kaya/_version.py"
root = "../.."
version_file = "src/kaya/rsgi/_version.py"
[tool.setuptools_scm.tag]
prefix = "release/"
prefix = "release/"
@@ -0,0 +1,11 @@
from ._rsgi import RsgiApplication, RsgiContext, RsgiWebSocket
from ._types import HTTPScope, WebSocketScope
__all__ = [
'RsgiApplication',
'RsgiContext',
'RsgiWebSocket',
'HTTPScope',
'WebSocketScope',
]
@@ -14,13 +14,17 @@ from typing import (
cast
)
from granian._granian import RSGIHTTPProtocol, RSGIHTTPScope, RSGIWebsocketProtocol, RSGIWebsocketScope, RSGIWebsocketTransport # type: ignore[attr-defined]
from granian._granian import ( # type: ignore[attr-defined]
RSGIHTTPProtocol,
RSGIHTTPScope,
RSGIWebsocketProtocol,
RSGIWebsocketScope,
RSGIWebsocketTransport,
)
from pwo import Maybe
from ._types import StrOrStrings
from ._http_context import HttpContext
from ._http_method import HttpMethod
from ._websocket import WebSocket, WebSocketMessage
from kaya.core import AbstractKayaApp, HttpContext, HttpMethod, WebSocket, WebSocketMessage
from kaya.core._types import StrOrStrings
class RsgiContext(HttpContext):
@@ -56,12 +60,6 @@ class RsgiContext(HttpContext):
self.request_body = cast(AsyncIterator[bytes], protocol)
self.protocol = protocol
# @staticmethod
# def _rearrange_headers(headers: Mapping[str, Sequence[str]]) -> List[Tuple[str, str]]:
# return list(
# ((key, value) for key, values in headers.items() for value in values)
# )
@staticmethod
def _rearrange_headers(headers: Mapping[str, StrOrStrings]) -> List[Tuple[str, str]]:
result = []
@@ -177,3 +175,26 @@ class RsgiWebSocket(WebSocket):
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)
@@ -1,17 +1,11 @@
from typing import (
Sequence,
TypedDict,
Literal,
Iterable,
Tuple,
Optional,
NotRequired,
Dict,
Any,
Union,
Mapping,
)
class HTTPScope(TypedDict):
proto: Literal['http']
rsgi_version: str
@@ -37,4 +31,10 @@ class WebSocketScope(TypedDict):
path: str
query_string: str
headers: Mapping[str, str]
authority: Optional[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))
+11 -149
View File
@@ -1,154 +1,16 @@
#
# This file is autogenerated by pip-compile with Python 3.14
# by the following command:
#
# pip-compile --allow-unsafe --extra=dev --extra=rsgi --output-file=requirements-dev.txt pyproject.toml
# Development dependencies for the Kaya monorepo.
# Install with:
# pip install -r requirements-dev.txt
#
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple
anyio==4.14.1
# via httpx
ast-serialize==0.5.0
# via mypy
asttokens==3.0.1
# via stack-data
build==1.5.0
# via kaya (pyproject.toml)
certifi==2026.6.17
# via
# httpcore
# httpx
# requests
cffi==2.0.0
# via cryptography
charset-normalizer==3.4.7
# via requests
click==8.4.2
# via granian
cryptography==49.0.0
# via secretstorage
decorator==5.3.1
# via
# ipdb
# ipython
docutils==0.23
# via readme-renderer
executing==2.2.1
# via stack-data
granian==2.7.7
# via kaya (pyproject.toml)
h11==0.16.0
# via httpcore
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via kaya (pyproject.toml)
id==1.6.1
# via twine
idna==3.18
# via
# anyio
# httpx
# requests
ipdb==0.13.13
# via kaya (pyproject.toml)
ipython==9.15.0
# via ipdb
ipython-pygments-lexers==1.1.1
# via ipython
jaraco-classes==3.4.0
# via keyring
jaraco-context==6.1.2
# via keyring
jaraco-functools==4.5.0
# via keyring
jedi==0.20.0
# via ipython
jeepney==0.9.0
# via
# keyring
# secretstorage
keyring==25.7.0
# via twine
librt==0.11.0
# via mypy
markdown-it-py==4.2.0
# via rich
matplotlib-inline==0.2.2
# via ipython
mdurl==0.1.2
# via markdown-it-py
more-itertools==11.1.0
# via
# jaraco-classes
# jaraco-functools
mypy==2.1.0
# via kaya (pyproject.toml)
mypy-extensions==1.1.0
# via mypy
nh3==0.3.6
# via readme-renderer
packaging==26.2
# via
# build
# twine
parso==0.8.7
# via jedi
pathspec==1.1.1
# via mypy
pexpect==4.9.0
# via ipython
prompt-toolkit==3.0.52
# via ipython
psutil==7.2.2
# via ipython
ptyprocess==0.7.0
# via pexpect
pure-eval==0.2.3
# via stack-data
pwo==0.1.2
# via kaya (pyproject.toml)
pycparser==3.0
# via cffi
pygments==2.20.0
# via
# ipython
# ipython-pygments-lexers
# readme-renderer
# rich
pyproject-hooks==1.2.0
# via build
readme-renderer==45.0
# via twine
requests==2.34.2
# via
# requests-toolbelt
# twine
requests-toolbelt==1.0.0
# via twine
rfc3986==2.0.0
# via twine
rich==15.0.0
# via twine
secretstorage==3.5.0
# via keyring
stack-data==0.6.3
# via ipython
traitlets==5.15.1
# via
# ipython
# matplotlib-inline
twine==6.2.0
# via kaya (pyproject.toml)
typing-extensions==4.15.0
# via
# mypy
# pwo
urllib3==2.7.0
# via
# id
# requests
# twine
wcwidth==0.8.1
# via prompt-toolkit
build
mypy
ipdb
twine
httpx
granian
pwo
typing-extensions
-17
View File
@@ -1,17 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.14
# by the following command:
#
# pip-compile --allow-unsafe --extra=rsgi --output-file=requirements.txt pyproject.toml
#
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple
click==8.4.2
# via granian
granian==2.7.7
# via kaya (pyproject.toml)
pwo==0.1.2
# via kaya (pyproject.toml)
typing-extensions==4.15.0
# via pwo
-12
View File
@@ -1,12 +0,0 @@
from dataclasses import dataclass
from typing import (
Optional,
Dict,
List,
)
from ._types import NodeType
from ._path_handler import PathHandler
from ._path_matcher import PathMatcher
-92
View File
@@ -1,92 +0,0 @@
from typing import (
TypedDict,
Literal,
Iterable,
Tuple,
Optional,
NotRequired,
Dict,
Any,
Union,
Mapping,
Sequence
)
from .base import StrOrStrings, PathMatcherResult
from kaya._http_method import HttpMethod
type NodeType = (str | HttpMethod)
class ASGIVersions(TypedDict):
spec_version: str
version: Union[Literal["2.0"], Literal["3.0"]]
class HTTPScope(TypedDict):
type: Literal["http"]
asgi: ASGIVersions
http_version: str
method: str
scheme: str
path: str
raw_path: bytes
query_string: bytes
root_path: str
headers: Iterable[Tuple[bytes, bytes]]
client: Optional[Tuple[str, int]]
server: Optional[Tuple[str, Optional[int]]]
state: NotRequired[Dict[str, Any]]
extensions: Optional[Dict[str, Dict[object, object]]]
class WebSocketScope(TypedDict):
type: Literal["websocket"]
asgi: ASGIVersions
http_version: str
scheme: str
path: str
raw_path: bytes
query_string: bytes
root_path: str
headers: Iterable[Tuple[bytes, bytes]]
client: Optional[Tuple[str, int]]
server: Optional[Tuple[str, Optional[int]]]
subprotocols: Iterable[str]
state: NotRequired[Dict[str, Any]]
extensions: Optional[Dict[str, Dict[object, object]]]
class LifespanScope(TypedDict):
type: Literal["lifespan"]
asgi: ASGIVersions
state: NotRequired[Dict[str, Any]]
class RSGI:
class Scope(TypedDict):
proto: Literal['http'] # = '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]
__all__ = [
'HttpMethod',
'HTTPScope',
'LifespanScope',
'RSGI',
'ASGIVersions',
'WebSocketScope',
'NodeType',
'StrOrStrings',
'PathMatcherResult'
]