slick-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
slick/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """slick - a slick, minimal ASGI web framework built on the standard library.
2
+
3
+ Quick taste::
4
+
5
+ from slick import Slick
6
+
7
+ app = Slick()
8
+
9
+ @app.get("/hello/{name}")
10
+ async def hello(request, name: str):
11
+ return {"message": f"Hello, {name}!"}
12
+ """
13
+
14
+ from .app import CallNext, Slick
15
+ from .datastructures import Headers, QueryParams, State
16
+ from .exceptions import HTTPException
17
+ from .requests import Request
18
+ from .responses import (
19
+ HTMLResponse,
20
+ JSONResponse,
21
+ PlainTextResponse,
22
+ RedirectResponse,
23
+ Response,
24
+ to_response,
25
+ )
26
+ from .routing import Route, Router
27
+ from .server import serve
28
+ from .testclient import TestClient, TestResponse
29
+
30
+ __version__ = "0.1.0"
31
+
32
+ __all__ = [
33
+ "Slick",
34
+ "Router",
35
+ "Route",
36
+ "Request",
37
+ "Response",
38
+ "JSONResponse",
39
+ "PlainTextResponse",
40
+ "HTMLResponse",
41
+ "RedirectResponse",
42
+ "HTTPException",
43
+ "Headers",
44
+ "QueryParams",
45
+ "State",
46
+ "CallNext",
47
+ "TestClient",
48
+ "TestResponse",
49
+ "serve",
50
+ "to_response",
51
+ "__version__",
52
+ ]
slick/app.py ADDED
@@ -0,0 +1,203 @@
1
+ """The :class:`Slick` ASGI application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import logging
8
+ import traceback
9
+ from typing import Any, Awaitable, Callable, Optional, TypeVar
10
+
11
+ from .datastructures import State
12
+ from .exceptions import HTTPException
13
+ from .requests import Request
14
+ from .responses import JSONResponse, PlainTextResponse, Response, to_response
15
+ from .routing import Handler, Router
16
+ from .types import Receive, Scope, Send
17
+
18
+ __all__ = ["Slick", "CallNext", "Middleware", "ExceptionHandler"]
19
+
20
+ logger = logging.getLogger("slick")
21
+
22
+ CallNext = Callable[[Request], Awaitable[Response]]
23
+ Middleware = Callable[[Request, CallNext], Awaitable[Response]]
24
+ ExceptionHandler = Callable[[Request, Exception], Any]
25
+ LifespanHook = Callable[[], Any]
26
+
27
+ H = TypeVar("H", bound=Handler)
28
+ M = TypeVar("M", bound=Middleware)
29
+ E = TypeVar("E", bound=ExceptionHandler)
30
+ L = TypeVar("L", bound=LifespanHook)
31
+
32
+
33
+ async def _maybe_await(value: Any) -> Any:
34
+ if inspect.isawaitable(value):
35
+ return await value
36
+ return value
37
+
38
+
39
+ class Slick(Router):
40
+ """A minimal ASGI 3 application.
41
+
42
+ ``Slick`` *is* a :class:`~slick.routing.Router`, so routes are added with
43
+ ``@app.get(...)``, ``@app.post(...)``, ``@app.route(...)`` etc. An
44
+ instance is an ASGI callable: run it with any ASGI server, the built-in
45
+ :func:`slick.serve` development server, or :class:`slick.TestClient`.
46
+
47
+ :param debug: when ``True`` unhandled errors return the traceback as the
48
+ ``500`` response body instead of a generic JSON message.
49
+ """
50
+
51
+ def __init__(self, *, debug: bool = False) -> None:
52
+ super().__init__()
53
+ self.debug = debug
54
+ self.state = State()
55
+ self._middleware: list[Middleware] = []
56
+ self._exception_handlers: dict[type[BaseException], ExceptionHandler] = {}
57
+ self._startup: list[LifespanHook] = []
58
+ self._shutdown: list[LifespanHook] = []
59
+ self._param_cache: dict[Handler, Optional[set[str]]] = {}
60
+
61
+ # ------------------------------------------------------------------ hooks
62
+
63
+ def middleware(self, func: M) -> M:
64
+ """Register an ``async def mw(request, call_next) -> Response``.
65
+
66
+ The first middleware registered is the outermost one: it sees the
67
+ request first and the response last.
68
+ """
69
+ self._middleware.append(func)
70
+ return func
71
+
72
+ def exception_handler(self, exc_class: type[Exception]) -> Callable[[E], E]:
73
+ """Register ``handler(request, exc)`` for ``exc_class`` and subclasses.
74
+
75
+ The handler may be sync or async and may return anything a route
76
+ handler can return. Registering for :class:`HTTPException` overrides
77
+ the default ``{"detail": ...}`` JSON error body.
78
+ """
79
+
80
+ def decorator(handler: E) -> E:
81
+ self._exception_handlers[exc_class] = handler
82
+ return handler
83
+
84
+ return decorator
85
+
86
+ def on_startup(self, func: L) -> L:
87
+ """Run ``func()`` (sync or async) when the ASGI lifespan starts."""
88
+ self._startup.append(func)
89
+ return func
90
+
91
+ def on_shutdown(self, func: L) -> L:
92
+ """Run ``func()`` (sync or async) when the ASGI lifespan ends."""
93
+ self._shutdown.append(func)
94
+ return func
95
+
96
+ # ------------------------------------------------------------------- ASGI
97
+
98
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
99
+ """ASGI 3 entry point."""
100
+ if scope["type"] == "lifespan":
101
+ await self._lifespan(receive, send)
102
+ return
103
+ if scope["type"] != "http":
104
+ raise RuntimeError(f"Slick does not support {scope['type']!r} connections")
105
+
106
+ scope["app"] = self
107
+ request = Request(scope, receive)
108
+ try:
109
+ response = await self._build_chain()(request)
110
+ except Exception:
111
+ logger.exception("Unhandled error while handling %s %s", request.method, request.path)
112
+ if self.debug:
113
+ response = PlainTextResponse(traceback.format_exc(), 500)
114
+ else:
115
+ response = JSONResponse({"detail": "Internal Server Error"}, 500)
116
+ await response(send)
117
+ # Re-raise so servers can log it and TestClient can surface it.
118
+ raise
119
+ await response(send, include_body=request.method != "HEAD")
120
+
121
+ async def _lifespan(self, receive: Receive, send: Send) -> None:
122
+ while True:
123
+ message = await receive()
124
+ if message["type"] == "lifespan.startup":
125
+ try:
126
+ for hook in self._startup:
127
+ await _maybe_await(hook())
128
+ except Exception:
129
+ await send({"type": "lifespan.startup.failed", "message": traceback.format_exc()})
130
+ raise
131
+ await send({"type": "lifespan.startup.complete"})
132
+ elif message["type"] == "lifespan.shutdown":
133
+ try:
134
+ for hook in self._shutdown:
135
+ await _maybe_await(hook())
136
+ except Exception:
137
+ await send({"type": "lifespan.shutdown.failed", "message": traceback.format_exc()})
138
+ raise
139
+ await send({"type": "lifespan.shutdown.complete"})
140
+ return
141
+
142
+ # --------------------------------------------------------------- dispatch
143
+
144
+ def _build_chain(self) -> CallNext:
145
+ call_next: CallNext = self._dispatch
146
+ for mw in reversed(self._middleware):
147
+ call_next = self._wrap(mw, call_next)
148
+ return call_next
149
+
150
+ @staticmethod
151
+ def _wrap(mw: Middleware, inner: CallNext) -> CallNext:
152
+ async def call(request: Request) -> Response:
153
+ return to_response(await mw(request, inner))
154
+
155
+ return call
156
+
157
+ async def _dispatch(self, request: Request) -> Response:
158
+ """Route the request, call the handler and apply exception handlers."""
159
+ try:
160
+ route, params = self.resolve(request.method, request.path)
161
+ request.scope["path_params"] = params
162
+ request.scope["route"] = route
163
+ result = await self._call_handler(route.handler, request, params)
164
+ return to_response(result)
165
+ except Exception as exc:
166
+ handler = self._lookup_exception_handler(exc)
167
+ if handler is not None:
168
+ return to_response(await _maybe_await(handler(request, exc)))
169
+ if isinstance(exc, HTTPException):
170
+ return JSONResponse({"detail": exc.detail}, exc.status_code, exc.headers)
171
+ raise
172
+
173
+ def _lookup_exception_handler(self, exc: Exception) -> Optional[ExceptionHandler]:
174
+ for cls in type(exc).__mro__:
175
+ if cls in self._exception_handlers:
176
+ return self._exception_handlers[cls]
177
+ return None
178
+
179
+ async def _call_handler(self, handler: Handler, request: Request, params: dict[str, Any]) -> Any:
180
+ """Call ``handler(request, **matching_path_params)``.
181
+
182
+ Path params are passed as keyword arguments only when the handler's
183
+ signature names them (or accepts ``**kwargs``). Sync handlers run in a
184
+ worker thread so they don't block the event loop.
185
+ """
186
+ accepted = self._accepted_params(handler)
187
+ kwargs = params if accepted is None else {k: v for k, v in params.items() if k in accepted}
188
+ if inspect.iscoroutinefunction(handler):
189
+ return await handler(request, **kwargs)
190
+ return await _maybe_await(await asyncio.to_thread(handler, request, **kwargs))
191
+
192
+ def _accepted_params(self, handler: Handler) -> Optional[set[str]]:
193
+ """Names of keyword params ``handler`` takes; ``None`` means "any"."""
194
+ if handler not in self._param_cache:
195
+ # Skip the first parameter: it always receives the request.
196
+ parameters = list(inspect.signature(handler).parameters.values())[1:]
197
+ if any(p.kind is p.VAR_KEYWORD for p in parameters):
198
+ self._param_cache[handler] = None
199
+ else:
200
+ self._param_cache[handler] = {
201
+ p.name for p in parameters if p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)
202
+ }
203
+ return self._param_cache[handler]
@@ -0,0 +1,132 @@
1
+ """Small immutable/mutable mapping types used by requests and responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Mapping
6
+ from typing import Iterable, Optional, Union
7
+ from urllib.parse import parse_qsl
8
+
9
+ __all__ = ["Headers", "QueryParams", "State"]
10
+
11
+ HeaderInput = Union[Mapping[str, str], Iterable[tuple[str, str]], None]
12
+
13
+
14
+ class Headers(Mapping[str, str]):
15
+ """A case-insensitive, multi-valued HTTP header collection.
16
+
17
+ Lookups via ``headers["Content-Type"]`` return the *first* value for that
18
+ name; use :meth:`getlist` to get all of them. Keys are stored lower-cased.
19
+ ``Headers`` is mutable through :meth:`__setitem__`, :meth:`append` and
20
+ :meth:`__delitem__` so that middleware can adjust response headers.
21
+ """
22
+
23
+ def __init__(self, headers: HeaderInput = None) -> None:
24
+ self._items: list[tuple[str, str]] = []
25
+ if headers is None:
26
+ return
27
+ pairs = headers.items() if isinstance(headers, Mapping) else headers
28
+ for key, value in pairs:
29
+ self._items.append((key.lower(), value))
30
+
31
+ @classmethod
32
+ def from_raw(cls, raw: Iterable[tuple[bytes, bytes]]) -> "Headers":
33
+ """Build headers from an ASGI ``[(name_bytes, value_bytes), ...]`` list."""
34
+ return cls((k.decode("latin-1"), v.decode("latin-1")) for k, v in raw)
35
+
36
+ def raw(self) -> list[tuple[bytes, bytes]]:
37
+ """Return the headers in ASGI wire format."""
38
+ return [(k.encode("latin-1"), v.encode("latin-1")) for k, v in self._items]
39
+
40
+ def __getitem__(self, key: str) -> str:
41
+ key = key.lower()
42
+ for k, v in self._items:
43
+ if k == key:
44
+ return v
45
+ raise KeyError(key)
46
+
47
+ def __setitem__(self, key: str, value: str) -> None:
48
+ """Replace every value of ``key`` with the single ``value``."""
49
+ key = key.lower()
50
+ self._items = [(k, v) for k, v in self._items if k != key]
51
+ self._items.append((key, value))
52
+
53
+ def __delitem__(self, key: str) -> None:
54
+ key = key.lower()
55
+ if key not in self:
56
+ raise KeyError(key)
57
+ self._items = [(k, v) for k, v in self._items if k != key]
58
+
59
+ def __contains__(self, key: object) -> bool:
60
+ return isinstance(key, str) and any(k == key.lower() for k, _ in self._items)
61
+
62
+ def __iter__(self) -> Iterator[str]:
63
+ seen: list[str] = []
64
+ for k, _ in self._items:
65
+ if k not in seen:
66
+ seen.append(k)
67
+ return iter(seen)
68
+
69
+ def __len__(self) -> int:
70
+ return len(set(k for k, _ in self._items))
71
+
72
+ def __repr__(self) -> str:
73
+ return f"Headers({self._items!r})"
74
+
75
+ def append(self, key: str, value: str) -> None:
76
+ """Add a value without removing existing values (e.g. ``Set-Cookie``)."""
77
+ self._items.append((key.lower(), value))
78
+
79
+ def getlist(self, key: str) -> list[str]:
80
+ """Return every value for ``key`` in order (possibly empty)."""
81
+ key = key.lower()
82
+ return [v for k, v in self._items if k == key]
83
+
84
+ def multi_items(self) -> list[tuple[str, str]]:
85
+ """Return all ``(name, value)`` pairs, including repeated names."""
86
+ return list(self._items)
87
+
88
+
89
+ class QueryParams(Mapping[str, str]):
90
+ """Parsed URL query string. ``params["x"]`` returns the first value."""
91
+
92
+ def __init__(self, query_string: Union[str, bytes] = "") -> None:
93
+ if isinstance(query_string, bytes):
94
+ query_string = query_string.decode("latin-1")
95
+ self._items: list[tuple[str, str]] = parse_qsl(query_string, keep_blank_values=True)
96
+
97
+ def __getitem__(self, key: str) -> str:
98
+ for k, v in self._items:
99
+ if k == key:
100
+ return v
101
+ raise KeyError(key)
102
+
103
+ def __iter__(self) -> Iterator[str]:
104
+ return iter(dict.fromkeys(k for k, _ in self._items))
105
+
106
+ def __len__(self) -> int:
107
+ return len(dict.fromkeys(k for k, _ in self._items))
108
+
109
+ def __repr__(self) -> str:
110
+ return f"QueryParams({self._items!r})"
111
+
112
+ def getlist(self, key: str) -> list[str]:
113
+ """Return every value for ``key`` (e.g. ``?tag=a&tag=b``)."""
114
+ return [v for k, v in self._items if k == key]
115
+
116
+ def multi_items(self) -> list[tuple[str, str]]:
117
+ """Return all ``(key, value)`` pairs, including repeated keys."""
118
+ return list(self._items)
119
+
120
+
121
+ class State:
122
+ """A plain attribute bag (``app.state.db = ...``, ``request.state.user = ...``)."""
123
+
124
+ def __init__(self, **values: object) -> None:
125
+ self.__dict__.update(values)
126
+
127
+ def __repr__(self) -> str:
128
+ return f"State({self.__dict__!r})"
129
+
130
+ def get(self, name: str, default: Optional[object] = None) -> object:
131
+ """Return attribute ``name`` or ``default`` if it is unset."""
132
+ return self.__dict__.get(name, default)
slick/exceptions.py ADDED
@@ -0,0 +1,39 @@
1
+ """Exceptions that the framework turns into HTTP responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from http import HTTPStatus
6
+ from typing import Any, Mapping, Optional
7
+
8
+ __all__ = ["HTTPException"]
9
+
10
+
11
+ class HTTPException(Exception):
12
+ """Raise from a handler or middleware to abort with an HTTP error.
13
+
14
+ By default it is rendered as ``{"detail": <detail>}`` JSON with the given
15
+ status code. ``detail`` defaults to the standard reason phrase.
16
+
17
+ Example::
18
+
19
+ raise HTTPException(404, "No such user")
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ status_code: int,
25
+ detail: Any = None,
26
+ headers: Optional[Mapping[str, str]] = None,
27
+ ) -> None:
28
+ if detail is None:
29
+ try:
30
+ detail = HTTPStatus(status_code).phrase
31
+ except ValueError:
32
+ detail = "Error"
33
+ super().__init__(status_code, detail)
34
+ self.status_code = status_code
35
+ self.detail = detail
36
+ self.headers: dict[str, str] = dict(headers or {})
37
+
38
+ def __repr__(self) -> str:
39
+ return f"HTTPException(status_code={self.status_code!r}, detail={self.detail!r})"
slick/py.typed ADDED
File without changes
slick/requests.py ADDED
@@ -0,0 +1,132 @@
1
+ """The :class:`Request` object passed to every handler and middleware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Optional
7
+ from urllib.parse import parse_qsl
8
+
9
+ from .datastructures import Headers, QueryParams, State
10
+ from .exceptions import HTTPException
11
+ from .types import Receive, Scope
12
+
13
+ __all__ = ["Request"]
14
+
15
+
16
+ class Request:
17
+ """A read-only view over an ASGI HTTP ``scope`` plus its body stream.
18
+
19
+ The body is read lazily the first time :meth:`body`, :meth:`text`,
20
+ :meth:`json` or :meth:`form` is awaited and cached afterwards.
21
+ """
22
+
23
+ def __init__(self, scope: Scope, receive: Optional[Receive] = None) -> None:
24
+ if scope.get("type") != "http":
25
+ raise ValueError("Request requires an ASGI 'http' scope")
26
+ self.scope = scope
27
+ self._receive = receive
28
+ self._body: Optional[bytes] = None
29
+ self._headers: Optional[Headers] = None
30
+ self._query: Optional[QueryParams] = None
31
+ # Per-request attribute bag, backed by the scope so it survives
32
+ # being wrapped in a new Request object.
33
+ self.state = State()
34
+ self.state.__dict__ = scope.setdefault("state", {})
35
+
36
+ @property
37
+ def method(self) -> str:
38
+ """Upper-case HTTP method, e.g. ``"GET"``."""
39
+ return self.scope["method"].upper()
40
+
41
+ @property
42
+ def path(self) -> str:
43
+ """The decoded request path, without query string."""
44
+ return self.scope["path"]
45
+
46
+ @property
47
+ def url(self) -> str:
48
+ """Reconstructed absolute URL (``scheme://host/path?query``)."""
49
+ scheme = self.scope.get("scheme", "http")
50
+ host = self.headers.get("host")
51
+ if host is None:
52
+ server = self.scope.get("server") or ("localhost", None)
53
+ host = server[0] if server[1] in (None, 80, 443) else f"{server[0]}:{server[1]}"
54
+ qs = self.scope.get("query_string", b"").decode("latin-1")
55
+ return f"{scheme}://{host}{self.path}" + (f"?{qs}" if qs else "")
56
+
57
+ @property
58
+ def headers(self) -> Headers:
59
+ """Case-insensitive request headers."""
60
+ if self._headers is None:
61
+ self._headers = Headers.from_raw(self.scope.get("headers", []))
62
+ return self._headers
63
+
64
+ @property
65
+ def query_params(self) -> QueryParams:
66
+ """Parsed query string (``request.query_params.get("page", "1")``)."""
67
+ if self._query is None:
68
+ self._query = QueryParams(self.scope.get("query_string", b""))
69
+ return self._query
70
+
71
+ @property
72
+ def path_params(self) -> dict[str, Any]:
73
+ """Values captured from the route pattern, already converted."""
74
+ return self.scope.setdefault("path_params", {})
75
+
76
+ @property
77
+ def cookies(self) -> dict[str, str]:
78
+ """Cookies parsed from the ``Cookie`` header."""
79
+ result: dict[str, str] = {}
80
+ for chunk in self.headers.get("cookie", "").split(";"):
81
+ if "=" in chunk:
82
+ key, _, value = chunk.strip().partition("=")
83
+ result[key] = value
84
+ return result
85
+
86
+ @property
87
+ def client(self) -> Optional[tuple[str, int]]:
88
+ """``(host, port)`` of the peer, if the server provided it."""
89
+ client = self.scope.get("client")
90
+ return tuple(client) if client else None # type: ignore[return-value]
91
+
92
+ @property
93
+ def app(self) -> Any:
94
+ """The :class:`~slick.Slick` application handling this request."""
95
+ return self.scope.get("app")
96
+
97
+ async def body(self) -> bytes:
98
+ """Return the full request body (read once, then cached)."""
99
+ if self._body is None:
100
+ chunks: list[bytes] = []
101
+ if self._receive is not None:
102
+ while True:
103
+ message = await self._receive()
104
+ if message["type"] == "http.disconnect":
105
+ break
106
+ chunks.append(message.get("body", b""))
107
+ if not message.get("more_body", False):
108
+ break
109
+ self._body = b"".join(chunks)
110
+ return self._body
111
+
112
+ async def text(self) -> str:
113
+ """Return the body decoded as UTF-8."""
114
+ return (await self.body()).decode("utf-8")
115
+
116
+ async def json(self) -> Any:
117
+ """Parse the body as JSON.
118
+
119
+ Raises :class:`HTTPException` ``400`` if the body is not valid JSON.
120
+ """
121
+ raw = await self.body()
122
+ try:
123
+ return json.loads(raw)
124
+ except (ValueError, UnicodeDecodeError) as exc:
125
+ raise HTTPException(400, "Invalid JSON body") from exc
126
+
127
+ async def form(self) -> dict[str, str]:
128
+ """Parse an ``application/x-www-form-urlencoded`` body."""
129
+ return dict(parse_qsl((await self.body()).decode("latin-1"), keep_blank_values=True))
130
+
131
+ def __repr__(self) -> str:
132
+ return f"Request({self.method} {self.path})"