diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..1d9e4bb --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,43 @@ +import asyncio +from typing import Any, Literal, overload + +from app.components.base import Component +from app.components.http import HTTPComponent as HTTPComponent +from app.components.lifespan import LifespanComponent as LifespanComponent +from app.types_ import AnyScope, PassthroughDecorator, Receive, Send + + +class App: + components: list[Component[Any, Any]] + + def __init__(self) -> None: + self.components = [] + + async def __call__(self, scope: AnyScope, receive: Receive[Any], send: Send) -> Any: + async with asyncio.TaskGroup() as tg: + for compo in self.components: + if await compo.condition(scope): + _ = tg.create_task(compo.handle(scope, receive, send)) + + @overload + def use_component[T: Component[Any, Any]](self, component: T) -> T: ... + + @overload + def use_component( + self, component: Literal[None] = ..., *args: Any, **kwds: Any + ) -> PassthroughDecorator[type[Component[Any, Any]]]: ... + + def use_component( + self, component: Component[Any, Any] | None = None, *args: Any, **kwds: Any + ) -> PassthroughDecorator[type[Component[Any, Any]]] | Component[Any, Any]: + if component is None: + + def _use_component( + component: type[Component[Any, Any]], / + ) -> type[Component[Any, Any]]: + self.components.append(component(*args, **kwds)) + return component + + return _use_component + self.components.append(component) + return component diff --git a/app/components/base.py b/app/components/base.py new file mode 100644 index 0000000..cf5a892 --- /dev/null +++ b/app/components/base.py @@ -0,0 +1,35 @@ +from abc import ABCMeta, abstractmethod +from collections.abc import MutableMapping +from typing import Any, TypeGuard + +from app.types_ import AnyScope, AsyncCallable, Receive, Send + + +class Component[S: AnyScope, R: Any](metaclass=ABCMeta): + def __init__(self, *args: Any, **kwds: Any) -> None: + pass + + async def condition(self, scope: AnyScope) -> TypeGuard[S]: # pyright: ignore[reportUnusedParameter] + """Determine whether the component should run.""" + return True + + @abstractmethod + async def handle(self, scope: S, receive: Receive[R], send: Send) -> None: + """Component processor.""" + raise NotImplementedError + + +class RouteComponent[S: AnyScope, Recv_T: Any, Route_T: MutableMapping[str, Any], Route_R: Any](Component[S, Recv_T], metaclass=ABCMeta): + routes: Route_T + + def __init__(self, *args: Any, **kwds: Any) -> None: + super().__init__(*args, **kwds) + + @abstractmethod + async def route_dispatch(self, scope: S, receive: Receive[Recv_T], send: Send) -> Any: + """Route dispatcher""" + raise NotImplementedError + + def route_install(self, type_: str, route: str, target: AsyncCallable[..., Route_R]) -> None: + """Install route target for specific type and route.""" + self.routes.setdefault(type_, {})[route] = target \ No newline at end of file diff --git a/app/components/http.py b/app/components/http.py new file mode 100644 index 0000000..d307fed --- /dev/null +++ b/app/components/http.py @@ -0,0 +1,99 @@ +from typing import TypeGuard, override + +from app.subroutines.http import Response, SimpleRequest, SimpleResponse +from app.types_ import ( + AnyScope, + AsyncCallable, + HTTPScope, + PassthroughDecorator, + Receive, + ReceiveHTTP, + RouteMapping, + Send, +) + +from .base import RouteComponent as _RouteComponent + + +class HTTPComponent( + _RouteComponent[HTTPScope, ReceiveHTTP, dict[str, RouteMapping[Response]], Response] +): + routes: dict[str, RouteMapping[Response]] + + def __init__(self) -> None: + self.routes = {} + super().__init__() + + @override + async def condition(self, scope: AnyScope) -> TypeGuard[HTTPScope]: + return scope["type"] == "http" + + @override + async def handle( + self, scope: HTTPScope, receive: Receive[ReceiveHTTP], send: Send + ) -> None: + req = SimpleRequest() + while not req.receive(await receive()): + pass + + print(scope, req.body) + resp = await self.route_dispatch(scope, receive, send) + if resp is None: + resp = Response(status=404, body=b"404 Not Found\n") + + async with SimpleResponse(send).prepare( + resp.status, headers=resp.headers + ) as rsp: + await rsp.finish(resp.body if resp.body is not None else b"") + + return None + + @override + async def route_dispatch( + self, scope: HTTPScope, receive: Receive[ReceiveHTTP], send: Send + ) -> Response | None: + for k, callee in self.routes[scope["method"].upper()].items(): + if scope["path"] == k: # temporary impl. + return await callee() + + def route[T: AsyncCallable[..., Response]]( + self, + route: str, + *, + get: bool = False, + post: bool = False, + put: bool = False, + delete: bool = False, + ) -> PassthroughDecorator[T]: + def __wrap_route(fn: T) -> T: + if get: + self.route_install("GET", route, fn) + if post: + self.route_install("POST", route, fn) + if put: + self.route_install("PUT", route, fn) + if delete: + self.route_install("DELETE", route, fn) + return fn + + return __wrap_route + + def get[T: AsyncCallable[..., Response]]( + self, route: str + ) -> PassthroughDecorator[T]: + return self.route(route, get=True) + + def post[T: AsyncCallable[..., Response]]( + self, route: str + ) -> PassthroughDecorator[T]: + return self.route(route, post=True) + + def put[T: AsyncCallable[..., Response]]( + self, route: str + ) -> PassthroughDecorator[T]: + return self.route(route, put=True) + + def delete[T: AsyncCallable[..., Response]]( + self, route: str + ) -> PassthroughDecorator[T]: + return self.route(route, delete=True) diff --git a/app/components/lifespan.py b/app/components/lifespan.py new file mode 100644 index 0000000..efb9171 --- /dev/null +++ b/app/components/lifespan.py @@ -0,0 +1,25 @@ +from typing import TypeGuard, override + +from app.types_ import AnyScope, LifespanScope, Receive, ReceiveLifespan, Send + +from .base import Component as _Component + + +class LifespanComponent(_Component[LifespanScope, ReceiveLifespan]): + @override + async def condition(self, scope: AnyScope) -> TypeGuard[LifespanScope]: + return scope["type"] == "lifespan" + + @override + async def handle(self, scope: LifespanScope, receive: Receive[ReceiveLifespan], send: Send) -> None: + while True: + message = await receive() + if message['type'] == 'lifespan.startup': + ... # Do some startup here! + print("Startup...") + await send({'type': 'lifespan.startup.complete'}) + elif message['type'] == 'lifespan.shutdown': + ... # Do some shutdown here! + print("Shutdown...") + await send({'type': 'lifespan.shutdown.complete'}) + return \ No newline at end of file diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 0000000..39c676b --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,10 @@ +class AppError(Exception): + pass + + +class ConnectionClosed(AppError): + pass + + +class ParseError(AppError): + pass diff --git a/app/subroutines/_c_route.c b/app/subroutines/_c_route.c new file mode 100644 index 0000000..ae929ac --- /dev/null +++ b/app/subroutines/_c_route.c @@ -0,0 +1,152 @@ +#include +#include +#include +#include +#include + +#define SEP 0x01 +#define FORMAT_START 0x03 +#define FORMAT_END 0x00 +#define DOUBLE_STAR 0x09 + +void free_parsed_route(char *parsed) { + free(parsed); +} + +// Helper function to write an Exact component to the output buffer +static void write_exact(const char *value, size_t value_len, char **output, size_t *output_len) { + memcpy(*output, value, value_len); + *output += value_len; + **output = SEP; + (*output)++; + *output_len += value_len + 1; +} + +// Helper function to write a Format component to the output buffer +static void write_format(const char *name, size_t name_len, size_t minl, size_t maxl, int ignore_sep, char **output, size_t *output_len) { + // Write FORMAT_START marker + **output = FORMAT_START; + (*output)++; + *output_len += 1; + + // Write the name + memcpy(*output, name, name_len); + *output += name_len; + **output = FORMAT_END; + (*output)++; + *output_len += name_len + 1; + + // Write minl and maxl as size_t values + memcpy(*output, &minl, sizeof(size_t)); + *output += sizeof(size_t); + memcpy(*output, &maxl, sizeof(size_t)); + *output += sizeof(size_t); + *output_len += 2 * sizeof(size_t); + + // Write ignore_sep as a single byte + **output = (char)ignore_sep; + (*output)++; + **output = SEP; + (*output)++; + *output_len += 2; +} + +// Main parsing function +char *parse_route(const char *route, size_t route_len, size_t *output_len) { + char *output = malloc(route_len * 8); // Allocate enough space for the output + if (!output) return NULL; + char *output_start = output; + *output_len = 0; + + const char *p = route; + const char *end = route + route_len; + + while (p < end) { + if (*p == '/') { + // Skip leading slashes + write_exact("/", 1, &output, output_len); + p++; + continue; + } else if (*p == '{') { + // Handle Format component + const char *start = p + 1; // Skip '{' + const char *end_brace = memchr(start, '}', end - start); + if (!end_brace) break; // Invalid format, stop parsing + + // Extract name and constraints + const char *colon = memchr(start, ':', end_brace - start); + size_t name_len = colon ? (size_t)(colon - start) : (size_t)(end_brace - start); + const char *name = start; + size_t minl = 0, maxl = (size_t)-1, ignore_sep = 0; + + if (colon) { + const char *constraint = colon + 1; + if (memchr(constraint, '-', end_brace - constraint)) { + // Parse "minl-maxl" + sscanf(constraint, "%zu-%zu", &minl, &maxl); + } else { + // Parse "minl" (fixed length) + sscanf(constraint, "%zu", &minl); + maxl = minl; + } + } + + // Write the Format component + write_format(name, name_len, minl, maxl, ignore_sep, &output, output_len); + + p = end_brace + 1; // Move past '}' + } else if (*p == '*' && *(p + 1) == '*') { + // Handle "**" as a special marker (\x09) + *output = DOUBLE_STAR; + output++; + *output = SEP; + output++; + *output_len += 2; + p += 2; // Move past "**" + } else { + // Handle Exact component + const char *next_slash = memchr(p, '/', end - p); + size_t segment_len = next_slash ? (size_t)(next_slash - p) : (size_t)(end - p); + write_exact(p, segment_len, &output, output_len); + p += segment_len; + } + } + + // Null-terminate the output + *output = '\0'; + *output_len += 1; + + return output_start; +} + +// // Example usage +// int main() { +// const char *route = "/foo/{n:5}/bar/**/baz"; +// size_t route_len = strlen(route); +// size_t output_len; + +// char *result = parse_route(route, route_len, &output_len); +// if (!result) { +// fprintf(stderr, "Memory allocation failed\n"); +// return 1; +// } + +// printf("Parsed result (%zu bytes):\n", output_len); +// for (size_t i = 0; i < output_len; i++) { +// if (result[i] == SEP) { +// printf("\\x01"); +// } else if (result[i] == FORMAT_START) { +// printf("\\x03"); +// } else if (result[i] == FORMAT_END) { +// printf("\\x00"); +// } else if (result[i] == DOUBLE_STAR) { +// printf("\\x09"); +// } else { +// printf("%c", result[i]); +// } +// } +// printf("\n"); + +// free(result); +// return 0; +// } diff --git a/app/subroutines/http.py b/app/subroutines/http.py new file mode 100644 index 0000000..86a2c50 --- /dev/null +++ b/app/subroutines/http.py @@ -0,0 +1,138 @@ +from collections.abc import MutableMapping +from dataclasses import InitVar, dataclass, field +from http.cookies import BaseCookie +from types import TracebackType +from typing import Any, Self + +from app.exceptions import ConnectionClosed +from app.types_ import CommonMapping, ReceiveHTTP, Send + + +class SimpleRequest: + body: bytes + body_parts: list[bytes] + body_complete: bool + done: bool + keep_unset_body: bool = False + + def __init__(self, keep_unset_body: bool | None = None) -> None: + self.body = b"" + self.body_parts = [] + self.body_complete = False + self.done = False + if keep_unset_body is not None: + self.keep_unset_body = keep_unset_body + + def receive(self, received: ReceiveHTTP) -> bool: + if received["type"] == "http.disconnect": + self.done = True + return True + + if "body" in received: + self.body_parts.append(received["body"]) + elif self.keep_unset_body: + self.body_parts.append(b"") + + if not ("more_body" in received and received["more_body"]): + self.body = b"".join(self.body_parts) + self.body_complete = True + + return self.body_complete or self.done + + +class SimpleResponse: + send: Send + headers: list[tuple[bytes, bytes]] + status: int + trailers: bool + done: bool + + def __init__(self, send: Send) -> None: + self.send = send + self.headers = [] + self.status = 200 + self.trailers = False + self.done = False + + def add_header(self, name: str, value: object) -> None: + self.headers.append((name.lower().encode(), str(value).encode())) + + def prepare(self, status: int = 200, trailers: bool = False, headers: MutableMapping[str, str] | None = None) -> Self: + self.status = status + self.trailers = trailers + if headers: + for k, v in headers.items(): + self.add_header(k, v) + return self + + async def start(self) -> None: + if self.done: + raise ConnectionClosed + await self.send({ + "type": "http.response.start", + "status": self.status, + "headers": self.headers, + "trailers": self.trailers + }) + + async def body(self, data: bytes = b"", *, done: bool = True) -> None: + if self.done: + raise ConnectionClosed + await self.send({ + "type": "http.response.body", + "body": data, + "more_body": not done + }) + self.done = done and not self.trailers + + async def part(self, data: bytes = b"") -> None: + """Same as `Response.body(data, done=False)`""" + return await self.body(data, done=False) + + async def finish(self, data: bytes = b"") -> None: + """Same as `Response.body(data, done=True)`""" + return await self.body(data, done=True) + + async def trail(self) -> None: + if self.done: + raise ConnectionClosed + await self.send({"type": "http.response.trailers"}) + self.done = True + + async def __aenter__(self) -> Self: + await self.start() + return self + + async def __aexit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, /) -> bool | None: + if not (self.done or exc_type): + await self.finish() + if exc_type and self.trailers: + await self.trail() + return None + + +@dataclass +class Response: + status: int = 200 + body: bytes | None = None + headers: CommonMapping = field(default_factory=dict[str, Any]) + content_type: InitVar[str | None] = None + cookies: InitVar[BaseCookie[bytes] | None] = None + + def __post_init__(self, content_type: str | None, cookies: BaseCookie[bytes] | None) -> None: + if self.body is not None: + self.headers["content-length"] = len(self.body) + if content_type is not None: + self.headers["content-type"] = content_type + if cookies is not None: + self.headers["set-cookie"] = cookies.output(header="").strip() + + +@dataclass +class HTMLResponse(Response): + content: InitVar[str] = "" + encoding: InitVar[str] = "utf-8" + + def __post_init__(self, content_type: str | None, cookies: BaseCookie[bytes] | None, content: str, encoding: str) -> None: + self.body: bytes | None = content.encode(encoding) + return super().__post_init__("text/html", cookies) diff --git a/app/subroutines/route.py b/app/subroutines/route.py new file mode 100644 index 0000000..cbd6456 --- /dev/null +++ b/app/subroutines/route.py @@ -0,0 +1,48 @@ +# / -> Exact("/") +# /foo -> Exact("/foo") +# /foo/ -> Exact("/foo/") +# /foo/bar -> Exact("/foo/bar") +# /foo/bar/{n} -> Exact("/foo/bar/"), Format("n") +# /foo/{n}/bar -> Exact("/foo/"), Format("n"), Exact("/bar") +# /foo{n:5}/bar -> Exact("/foo"), Format("n", minl=5, maxl=5), Exact("/bar") +# /foo/{m:1-}/{n:3-5}bar -> Exact("/foo/"), Format("m", minl=1, maxl=-1), Exact("/"), Format("n", minl=3, maxl=5), Exact("bar") +# /* -> Exact("/"), Format("_") +# /foo/*bar -> Exact("/foo/"), Format("_"), Exact("bar") +# /foo/*/bar -> Exact("/foo/"), Format("_"), Exact("/bar") +# /foo/**/bar -> Exact("/foo/"), Format("_", ignore_sep=True), Exact("/bar") + +# ["foo", Format("m", minl=1, maxl=-1), [Format("n", minl=3, maxl=5), "bar"], "*"] + + +import ctypes + +dll = ctypes.CDLL("./c_route.so") +dll.parse_route.restype = type("c_char_alloc", (ctypes.c_char_p,), {}) +dll.free_parsed_route.argtypes = (ctypes.c_void_p,) +dll.free_parsed_route.restype = None + + +def compile_route(pattern: str) -> list[bytes]: + if pattern[0] != "/": + from app.exceptions import ParseError + raise ParseError("The first character in route pattern must be '/'.") + + # res: list[str] = [] + # for path in pattern[1:].split("/"): + # if (star_idx := path.find("*")) != -1: + # path = "".join((path[:star_idx], "{_}", path[star_idx + 1:])) + # if "{" not in path and "}" not in path and "*" not in path: + # res.append(path) + + outlen = ctypes.c_size_t() + data = pattern[1:].encode() + ret = dll.parse_route(data, len(data), ctypes.byref(outlen)) + res = ctypes.string_at(ret, outlen.value - 2).split(b"\x01") + dll.free_parsed_route(ret) + + return res + + +if __name__ == "__main__": + print(compile_route("/foo/{n:5}foo/bar/**/baz")) + print(compile_route("/")) diff --git a/app/types_.py b/app/types_.py new file mode 100644 index 0000000..c96cc11 --- /dev/null +++ b/app/types_.py @@ -0,0 +1,69 @@ +from collections.abc import ( + Awaitable, + Coroutine, + Mapping, + MutableMapping, + MutableSequence, +) +from typing import Any, Callable, Literal, NotRequired, TypedDict + +type CommonMapping = MutableMapping[str, Any] + +type PassthroughDecorator[F: Callable[..., Any]] = Callable[[F], F] +type WrappingDecorator[**P, R] = Callable[[Callable[P, R]], Callable[P, R]] + +type HostPortTuple = tuple[str, int] +type UnixSocketTuple = tuple[str, Literal[None]] +type Receive[R: Mapping[str, Any]] = Callable[[], Coroutine[Any, Any, R]] +type Send = Callable[[CommonMapping], Coroutine[Any, Any, None]] + +type AsyncCallable[**P, R] = Callable[P, Awaitable[R]] +type AnyAsyncCallable = AsyncCallable[..., Any] +type RouteMapping[R] = MutableMapping[str, AsyncCallable[..., R]] + + +class ASGIInfo(TypedDict): + version: str + spec_version: str + + +class LifespanScope(TypedDict): + type: Literal["lifespan"] + asgi: ASGIInfo + state: CommonMapping + + +class HTTPScope(TypedDict): + type: Literal["http"] + asgi: ASGIInfo + http_version: str + server: HostPortTuple | UnixSocketTuple + client: HostPortTuple + scheme: str + method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + root_path: str + path: str + raw_path: bytes + query_string: bytes + headers: MutableSequence[tuple[str, str]] + state: CommonMapping + + +type AnyScope = LifespanScope | HTTPScope + + +class ReceiveLifespan(TypedDict): + type: Literal["lifespan.startup", "lifespan.shutdown"] + + +class ReceiveHTTPRequest(TypedDict): + type: Literal["http.request"] + body: NotRequired[bytes] + more_body: NotRequired[bool] + + +class ReceiveHTTPDisconnect(TypedDict): + type: Literal["http.disconnect"] + + +type ReceiveHTTP = ReceiveHTTPRequest | ReceiveHTTPDisconnect diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cce2d9d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[tool.basedpyright] +typeCheckingMode = "recommended" +reportAny = false +reportExplicitAny = false \ No newline at end of file diff --git a/test.py b/test.py new file mode 100644 index 0000000..7e0e126 --- /dev/null +++ b/test.py @@ -0,0 +1,27 @@ +from app import App +from app.components.http import HTTPComponent + +# from app.components.lifespan import LifespanComponent +from app.subroutines.http import HTMLResponse + +app = App() + +# lifespan = app.use_component(LifespanComponent()) +http = app.use_component(HTTPComponent()) + + +@http.route("/teapot", get=True, post=True, put=True, delete=True) +async def teapot() -> HTMLResponse: + resp = """ + + + + I'm a Teapot!!! + + +

I'm a Teapot!!!

+

I've already told you I'm a teapot.

+ + + """ + return HTMLResponse(status=418, content=resp)