diff --git a/basgi/__init__.py b/basgi/__init__.py index d3889f1..981eb76 100644 --- a/basgi/__init__.py +++ b/basgi/__init__.py @@ -1,31 +1,27 @@ __version__ = "0.1.0" -try: - from .application import Application, ExceptionType, ExceptionCallback - from .enums import HttpStatus - from .error import HttpError - from .misc import Color - from .request import Request - from .response import Response, TemplateResponse - from .runner import run_app - from .signal import Signal, SignalCallback - from .template import Template +from .application import Application, ExceptionType, ExceptionCallback +from .enums import HttpStatus +from .error import HttpError +from .misc import Color, StateProxy, Stream +from .request import Request +from .response import Response, FileResponse, TemplateResponse +from .runner import run_app +from .signal import Signal, SignalCallback +from .template import Template - from .router import ( - Router, - get_router, - route, - connect, - delete, - get, - head, - options, - patch, - post, - put, - trace - ) - -except ModuleNotFoundError: - pass +from .router import ( + Router, + get_router, + route, + connect, + delete, + get, + head, + options, + patch, + post, + put, + trace +) diff --git a/basgi/application.py b/basgi/application.py index 6c5c870..230e3d7 100644 --- a/basgi/application.py +++ b/basgi/application.py @@ -1,16 +1,19 @@ +from __future__ import annotations + import bsql import traceback -from collections.abc import Awaitable, Callable +from collections.abc import Callable +from functools import lru_cache +from os.path import normpath from pathlib import Path from typing import Any, TypeVar from .error import HttpError -from .misc import State -from .objtypes import ScopeType -from .request import Request -from .response import Response, TemplateResponse -from .router import Router, get_router +from .misc import StateProxy, Stream, ReaderFunction, WriterFunction +from .request import Request, ScopeType +from .response import FileResponse, Response +from .router import Router, RouteHandler, get_router from .signal import Signal from .template import Template, TemplateContextType @@ -18,32 +21,26 @@ from .template import Template, TemplateContextType ExceptionType = TypeVar("ExceptionType", bound = Exception) ExceptionCallback = Callable[[Request, ExceptionType], Response] +APPLICATIONS: dict[str, Application] = {} + class Application: def __init__(self, name: str, - app_path: str, - address: str = "127.0.0.1", - port: int = 8080, - workers: int = 1, - allowed_ips: list[str] | None = None, - env: dict[str, str] | None = None, - dev: bool = False, - reload_dirs: list[Path | str] | None = None, template_search: list[Path | str] | None = None, template_env: dict[str, Any] | None = None, - template_context: TemplateContextType | None = None) -> None: + template_context: TemplateContextType | None = None, + request_class: type[Request] = Request, + request_state_class: type[StateProxy] = StateProxy, + app_state_class: type[StateProxy] = StateProxy) -> None: + + if name in APPLICATIONS: + raise ValueError(f"Application with name '{name}' already exists") self.name: str = name - self.app_path: str = app_path - self.address: str = address - self.port: int = port - self.workers: int = 1 - self.allowed_ips: list[str] = allowed_ips or [] - self.env: dict[str, str] = env or {} - self.dev: bool = dev - self.reload_dirs: list[Path | str] = reload_dirs or [] - self.state: State = State() + self.state: StateProxy = app_state_class({}) + self.request_class: type[Request] = request_class + self.request_state_class: type[StateProxy] = request_state_class # Not sure how to properly annotate `Exception` here, so just ignore the warnings self.error_handlers: dict[type[ExceptionType], ExceptionCallback] = { # type: ignore @@ -58,41 +55,48 @@ class Application: context_function = template_context, **(template_env or {})) + APPLICATIONS[name] = self + + + @classmethod + def get(cls: type[Application], name: str) -> Application: + return APPLICATIONS[name] + async def __call__(self, - raw_scope: ScopeType, - receive: Callable[..., Awaitable[dict[str, Any]]], - send: Callable[[dict[str, Any]], Awaitable[None]]) -> None: + scope: ScopeType, + reader: ReaderFunction, + writer: WriterFunction) -> None: - if raw_scope["type"] == "lifespan": + if scope["type"] == "lifespan": while True: - message = await receive() + message = await reader() if message["type"] == "lifespan.startup": self.on_startup.emit() - await send({"type": "lifespan.startup.complete"}) + await writer({"type": "lifespan.startup.complete"}) elif message["type"] == "lifespan.shutdown": await self.on_shutdown.handle_emit() - await send({"type": "lifespan.shutdown.complete"}) + await writer({"type": "lifespan.shutdown.complete"}) break - elif raw_scope["type"] == "http": - request = Request(self, raw_scope) + elif scope["type"] == "http": + stream = Stream(reader, writer) response: Response | None = None try: + request = self.request_class(self, scope, stream) match = self.router(request.path, request.method) + + request.params = match.params or {} # type: ignore[assignment] await self.on_request.handle_emit(request) - response = await match.target(request, **(match.params or {})) + response = await match.target(request) if response is None: raise HttpError(500, "Empty response") - if isinstance(response, TemplateResponse): # type: ignore[unreachable] - response = response.get_response(request) # type: ignore[unreachable] - await self.on_response.handle_emit(request, response) except Exception as error: @@ -106,21 +110,9 @@ class Application: else: response = self.handle_error_default(request, error) + await response.send(stream, request) self.print_access_log(request, response) - await send({ - "type": "http.response.start", - "status": response.status, - "headers": tuple(response.headers.items()) - }) - - await send({ - "type": "http.response.body", - "body": response.body - }) - - return - @Signal(5.0) async def on_request(self, request: Request) -> None: @@ -142,6 +134,15 @@ class Application: pass + def add_route(self, method: str, path: str, handler: RouteHandler) -> None: + self.router.bind(handler, path, methods = [method]) + + + def add_statuc(self, path: str, location: Path | str, cached: bool = False) -> None: + handler = StaticHandler(path, location, cached) + self.add_route("GET", handler.path, handler) + + def print_access_log(self, request: Request, response: Response) -> None: message = "{}: {} \"{} {}\" {} {} \"{}\"".format( "INFO", @@ -174,3 +175,46 @@ class Application: def handle_error_default(self, request: Request, exception: Exception) -> Response: traceback.print_exc() return Response(500, "Internal Server Error") + + +class StaticHandler: + def __init__(self, path: str, location: Path | str, cached: bool) -> None: + if isinstance(location, str): + location = Path(location) + + self.path: str = path.rstrip("/") + "/{filepath}" + self.location: Path = Path(location).expanduser().resolve() + self.cached: bool = cached + + if not self.location.exists(): + raise FileNotFoundError(self.location) + + if not self.location.is_dir(): + raise ValueError("Location is not a directory or file") + + + async def __call__(self, request: Request) -> Response: + filepath = request.params["filepath"] + + if self.cached: + return await self.handle_call_cached(request, filepath) + + return await self.handle_call(request, filepath) + + + async def handle_call(self, request: Request, filepath: str) -> Response: + filepath = normpath(filepath) + path = self.location.joinpath(filepath.lstrip("/")) + + if path.is_dir(): + path = path.joinpath("index.html") + + if not path.is_file(): + raise HttpError(404, str(filepath)) + + return FileResponse(path) + + + @lru_cache(maxsize = 128, typed = True) + async def handle_call_cached(self, request: Request, filepath: str) -> Response: + return await self.handle_call(request, filepath) diff --git a/basgi/enums.py b/basgi/enums.py index d07dd5e..9ce640e 100644 --- a/basgi/enums.py +++ b/basgi/enums.py @@ -1,6 +1,6 @@ import re -from aputils import IntEnum +from aputils import IntEnum, StrEnum CAPITAL = re.compile("[A-Z][^A-Z]") @@ -84,3 +84,10 @@ class HttpStatus(IntEnum): @property def reason(self) -> str: return " ".join(CAPITAL.findall(self.name)) + + +class SassOutputStyle(StrEnum): + NESTED = "nested" + EXPANDED = "expanded" + COMPACT = "compact" + COMPRESSED = "compressed" diff --git a/basgi/misc.py b/basgi/misc.py index 83f4f9c..869a34d 100644 --- a/basgi/misc.py +++ b/basgi/misc.py @@ -2,11 +2,26 @@ import asyncio import json from aputils import JsonBase -from collections.abc import Iterable from colorsys import rgb_to_hls, hls_to_rgb from functools import cached_property from typing import Any, Self +from collections.abc import ( + Awaitable, + Callable, + Iterable, + Iterator, + ItemsView, + KeysView, + MutableMapping, + Sequence, + ValuesView +) + + +ReaderFunction = Callable[..., Awaitable[dict[str, Any]]] +WriterFunction = Callable[[dict[str, Any]], Awaitable[None]] + HTTP_STATUS = { 100: "Continue", @@ -320,7 +335,11 @@ class Color(str): return f"rgba({values}, {trans:.2})" -class State(dict[str, Any]): +class StateProxy(MutableMapping[str, Any]): + def __init__(self, state: dict[str, str]) -> None: + self._state: dict[str, str] = state + + def __getattr__(self, key: str) -> Any: try: object.__getattribute__(self, key) @@ -330,8 +349,122 @@ class State(dict[str, Any]): def __setattr__(self, key: str, value: Any) -> None: + if key.startswith("_"): + object.__setattr__(self, key, value) + return + self[key] = value def __delattr__(self, key: str) -> None: + if key.startswith("_"): + object.__delattr__(self, key) + return + del self[key] + + + def __getitem__(self, key: str) -> Any: + return self._state[key] + + + def __setitem__(self, key: str, value: Any) -> None: + self._state[key] = value + + + def __delitem__(self, key: str) -> None: + del self._state[key] + + + def __len__(self) -> int: + return len(self._state) + + + def __iter__(self) -> Iterator[str]: + for key in self._state: + yield key + + + def get(self, key: str, default: Any = None) -> Any: + return self._state.get(key, default) + + + def items(self) -> ItemsView[str, Any]: + return self._state.items() + + + def keys(self) -> KeysView[str]: + return self._state.keys() + + + def set(self, key: str, value: Any) -> None: + self._state[key] = value + + + def values(self) -> ValuesView[Any]: + return self._state.values() + + +class Stream: + def __init__(self, reader: ReaderFunction, writer: WriterFunction) -> None: + self.reader = reader + self.writer = writer + self._sent_headers: bool = False + self._sent_body: bool = False + + + def __aiter__(self) -> Self: + return self + + + async def __anext__(self) -> bytes: + data, more = await self.read_chunk() + + if not data and not more: + raise StopAsyncIteration + + return data + + + async def close(self) -> None: + await self.writer({"type": "http.disconnect"}) + + + async def read(self) -> bytes: + body = b"" + + while True: + data, more = await self.read_chunk() + body += data + + if not more: + break + + return body + + + async def read_chunk(self) -> tuple[bytes, bool]: + data = await self.reader() + return data.get("body", b""), data.get("more_body", False) + + + async def write_headers(self, status: int, headers: Sequence[tuple[str, str]]) -> None: + if self._sent_headers: + return + + await self.writer({ + "type": "http.response.start", + "status": status, + "headers": headers + }) + + + async def write_body(self, data: bytes, eof: bool = False) -> None: + if self._sent_body: + return + + await self.writer({ + "type": "http.response.body", + "body": data, + "more_body": eof + }) diff --git a/basgi/request.py b/basgi/request.py index 420eebe..d827216 100644 --- a/basgi/request.py +++ b/basgi/request.py @@ -1,13 +1,15 @@ from __future__ import annotations +import multipart import typing +from aputils import JsonBase from collections.abc import Iterable from multidict import CIMultiDict, CIMultiDictProxy from typing import Any, Literal, TypedDict from urllib.parse import parse_qsl -from .misc import State +from .misc import StateProxy, Stream if typing.TYPE_CHECKING: from .application import Application @@ -36,8 +38,14 @@ class ScopeType(TypedDict): class Request: - def __init__(self, app: Application, scope: ScopeType) -> None: + def __init__(self, + app: Application, + scope: ScopeType, + stream: Stream) -> None: + self.app: Application = app + self.stream: Stream = stream + self._body: bytes = b"" raw_query = parse_qsl(scope["query_string"].decode("utf-8"), keep_blank_values = True) raw_headers = ((k.decode("utf-8").title(), v.decode("utf-8")) for k, v in scope["headers"]) @@ -46,13 +54,66 @@ class Request: self.path: str = scope["path"] self.query: CIMultiDictProxy[str] = CIMultiDictProxy(CIMultiDict(raw_query)) self.headers: CIMultiDictProxy[str] = CIMultiDictProxy(CIMultiDict(raw_headers)) + self.params: dict[str, Any] = {} self.remote: str = (scope.get("client") or ("n/a"))[0] self.local: str = (scope.get("server") or ("n/a"))[0] - self.state: State = State(scope.get("state") or {}) + self.state: StateProxy = app.request_state_class(scope.get("state") or {}) self.extensions: dict[str, Any] = scope.get("extensions", {}) # type: ignore[assignment] # keep? self.asgi: tuple[str, str] = (scope["asgi"]["spec_version"], scope["asgi"]["version"]) self.version: float = float(scope["http_version"]) self.scheme: str = scope["scheme"].lower() + + + @property + def content_length(self) -> int: + return int(self.headers.get("Content-Length", "0")) + + + @property + def content_type(self) -> str: + return self.headers.getone("Content-Type", "") + + + async def body(self) -> bytes: + if not self._body: + self._body = await self.stream.read() + + return self._body + + + async def text(self) -> str: + return (await self.body()).decode("utf-8") + + + async def json(self, parser_class: type[JsonBase] = JsonBase) -> JsonBase: + return parser_class.parse(await self.body()) + + + async def form(self) -> CIMultiDictProxy[str | multipart.File]: + if self.content_type != "multipart/form-data": + raise ValueError(f"Invalid mimetype for form data: {self.content_type}") + + fields: CIMultiDict[str | multipart.File] = CIMultiDict({}) + + def handle_field(field: multipart.Field | multipart.File) -> None: + if isinstance(field, multipart.Field): + fields[field.field_name.decode("utf-8")] = field.value.decode("utf-8") + + else: + field.file_object.seek(0) + fields[field.field_name.decode("utf-8")] = field + + parser = multipart.create_form_parser(self.headers, handle_field, handle_field) + + async for chunk in self.stream: + parser.write(chunk) + + parser.finalize() + return CIMultiDictProxy(fields) + + + async def stream_response(self, status: int, headers: dict[str, str]) -> None: + pass diff --git a/basgi/response.py b/basgi/response.py index e674f06..479f739 100644 --- a/basgi/response.py +++ b/basgi/response.py @@ -1,11 +1,17 @@ +from __future__ import annotations + import os +from aputils import JsonBase +from mimetypes import guess_type from multidict import CIMultiDict + +from pathlib import Path from typing import Any, Self from .enums import HttpStatus from .error import HttpError -from .misc import convert_to_bytes +from .misc import Stream, convert_to_bytes from .request import Request @@ -14,12 +20,16 @@ class Response: status: HttpStatus | int, body: Any, mimetype: str | None = None, - headers: dict[str, str] | None = None) -> None: + headers: CIMultiDict[str] | dict[str, str] | None = None) -> None: self._body = b"" - self.status: HttpStatus = HttpStatus.parse(status) - self.headers: CIMultiDict[str] = CIMultiDict(headers or {}) + + if isinstance(headers, CIMultiDict): + self.headers = headers + + else: + self.headers = CIMultiDict(headers or {}) if body: self.body = body @@ -28,6 +38,26 @@ class Response: self.mimetype = mimetype + @classmethod + def new_json(cls: type[Self], + status: HttpStatus | int, + body: JsonBase | dict[str, Any] | str, + mimetype: str = "application/json", + headers: dict[str, str] | None = None) -> Self: + + return cls(status, body, mimetype, headers) + + + @classmethod + def new_activity(cls: type[Self], + status: HttpStatus | int, + body: JsonBase | dict[str, Any] | str, + mimetype: str = "application/activity+json", + headers: dict[str, str] | None = None) -> Self: + + return cls(status, body, mimetype, headers) + + @classmethod def new_from_http_error(cls: type[Self], error: HttpError) -> Self: message = f"HTTP Error {error.status.value}: {error.message}" @@ -42,21 +72,63 @@ class Response: @body.setter def body(self, value: Any) -> None: self._body = convert_to_bytes(value) - self.headers.popall("Content-Length", None) - self.headers["Content-Length"] = str(len(self._body)) + self.length = len(self._body) + + + @property + def length(self) -> int: + return int(self.headers.getone("Content-Length", "0")) + + + @length.setter + def length(self, value: int) -> None: + self.headers.update({"Content-Length": str(value)}) @property def mimetype(self) -> str: - return self.headers["Content-Type"] + return self.headers.getone("Content-Type", "") @mimetype.setter def mimetype(self, value: str) -> None: - self.headers["Content-Type"] = value + self.headers.update({"Content-Type": value}) -class TemplateResponse: + async def send(self, stream: Stream, request: Request) -> None: + await stream.write_headers(self.status, tuple(self.headers.items())) + await stream.write_body(self.body) + + +class FileResponse(Response): + def __init__(self, + path: Path, + mimetype: str | None = None, + chunk_size: int = 8192, + status: int = 200, + headers: dict[str, str] | None = None) -> None: + + Response.__init__(self, status, b"", headers = headers) + + self.path: Path = path + self.mimetype: str = mimetype or guess_type(path)[0] or "application/octet+stream" + self.chunk_size: int = chunk_size or 8192 + + + async def send(self, stream: Stream, request: Request) -> None: + await stream.write_headers(200, tuple([])) + + with self.path.open("rb") as fd: + while True: + if not (data := fd.read(self.chunk_size)): + break + + await stream.write_body(data, True) + + await stream.write_body(b"", False) + + +class TemplateResponse(Response): def __init__(self, name: str, status: HttpStatus | int = HttpStatus.Ok, @@ -65,10 +137,10 @@ class TemplateResponse: pretty_print: bool = False, **context: Any) -> None: + Response.__init__(self, status, b"", headers = headers) + self.name: str = name - self.status: HttpStatus = HttpStatus.parse(status) self.mimetype: str = mimetype or self.detect_mimetype() - self.headers: dict[str, Any] = headers or {} self.pretty_print: bool = pretty_print self.context: dict[str, Any] = context @@ -85,12 +157,11 @@ class TemplateResponse: if ext == ".xml": return "text/xml" - return "text/plain" + return guess_type(self.name)[0] or "text/plain" - def get_response(self, request: Request) -> Response: - return Response(self.status, self.render(request), self.mimetype, self.headers) + async def send(self, stream: Stream, request: Request) -> None: + text = request.app.template.render(self.name, self.pretty_print, **self.context) + self.body = text.encode("utf-8") - - def render(self, request: Request) -> str: - return request.app.template.render(self.name, self.pretty_print, **self.context) + await Response.send(self, stream, request) diff --git a/basgi/router.py b/basgi/router.py index 4bb2412..842c7dc 100644 --- a/basgi/router.py +++ b/basgi/router.py @@ -6,10 +6,10 @@ from typing import Any, Pattern from .error import HttpError from .request import Request -from .response import Response, TemplateResponse +from .response import Response -RouteHandler = Callable[[Request], Awaitable[Response | TemplateResponse]] +RouteHandler = Callable[[Request], Awaitable[Response]] class Router(http_router.Router): diff --git a/basgi/runner.py b/basgi/runner.py index f7d71ef..710c1d6 100644 --- a/basgi/runner.py +++ b/basgi/runner.py @@ -1,7 +1,8 @@ import asyncio import os -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Sequence +from pathlib import Path from typing import Any from uvicorn import Config, Server from uvicorn.supervisors import ChangeReload, Multiprocess @@ -12,31 +13,39 @@ from .application import Application BackgroundTask = Callable[[Application], Coroutine[Any, Any, None]] -def run_app(app: Application, *bgtasks: BackgroundTask) -> None: +def run_app(*args: Any, **kwargs: Any) -> None: + try: - asyncio.run(handle_run_server(app, *bgtasks)) + asyncio.run(handle_run_server(*args, **kwargs)) except KeyboardInterrupt: pass -async def handle_run_server(app: Application, *bgtasks: BackgroundTask) -> None: - for key, value in app.env.items(): - os.environ[key] = value - - os.environ["BARKSHARK_ASGI_NAME"] = app.name +async def handle_run_server( + app: Application, + app_spec: str | None = None, + address: str = "127.0.0.1", + port: int = 8080, + workers: int = 1, + allowed_ips: list[str] | None = None, + reload: bool = False, + reload_dirs: list[Path | str] | None = None, + bgtasks: Sequence[BackgroundTask] | None = None, + env: dict[str, str] | None = None, + dev: bool = False) -> None: cfg = Config( - app.app_path if app.workers > 1 else app, - host = app.address, - port = app.port, - workers = app.workers if not app.dev else 1, + app_spec if workers > 1 else app, # type: ignore[arg-type] + host = address, + port = port, + workers = workers if not dev else 1, access_log = False, log_level = "info", factory = True, - forwarded_allow_ips = app.allowed_ips or None, - reload = app.dev, - reload_dirs = [str(path) for path in app.reload_dirs], + forwarded_allow_ips = allowed_ips or None, + reload = dev, + reload_dirs = [str(path) for path in reload_dirs] if reload_dirs else [], reload_delay = 1, server_header = False, lifespan = "on", @@ -45,19 +54,22 @@ async def handle_run_server(app: Application, *bgtasks: BackgroundTask) -> None: ] ) + for key, value in (env or {}).items(): + os.environ[key] = value + tasks: list[asyncio.Task[Any]] = [] - for task in bgtasks: + for task in (bgtasks or []): tasks.append(asyncio.create_task(task(app))) server = Server(cfg) runner: ChangeReload | Multiprocess # type: ignore[valid-type] - if app.dev: + if dev: runner = ChangeReload(cfg, target = server.run, sockets = [cfg.bind_socket()]) runner.run() - elif cfg.workers > 1: + elif workers > 1: runner = Multiprocess(cfg, target = server.run, sockets = [cfg.bind_socket()]) runner.run() diff --git a/basgi/template.py b/basgi/template.py index 5e8f884..d5bcd23 100644 --- a/basgi/template.py +++ b/basgi/template.py @@ -1,15 +1,19 @@ from __future__ import annotations import os +import sass from collections.abc import Callable, Sequence from hamlish_jinja import HamlishExtension, OutputMode from jinja2 import Environment, FileSystemLoader +from jinja2.ext import Extension +from os.path import splitext from pathlib import Path from typing import Any from xml.dom import minidom from xml.etree import ElementTree +from .enums import SassOutputStyle from .misc import Color @@ -28,6 +32,60 @@ class FsLoader(FileSystemLoader): self.followlinks: bool = followlinks +class SassExtension(Extension): + "An extension for Jinja2 that adds support for sass and scss compiling." + + + def __init__(self, environment: Environment): + Extension.__init__(self, environment) + + self.output_style: SassOutputStyle = SassOutputStyle.NESTED + self.include_paths: list[str] = [] + self._exts: tuple[str, str] = (".sass", ".scss") + + environment.extend( # type: ignore[no-untyped-call] + sass_get_output_style = self.get_output_style, + sass_set_output_style = self.set_output_style, + sass_append_include_path = self.include_paths.append, + sass_remove_include_path = self.include_paths.remove + ) + + + def __repr__(self) -> str: + return f"SassExtension(output_style='{self.output_style.value}')" + + + def get_output_style(self) -> SassOutputStyle: + return self.output_style + + + def set_output_style(self, value: SassOutputStyle) -> None: + self.output_style = SassOutputStyle.parse(value) + + + def preprocess(self, source: str, name: str | None, filename: str | None = None) -> str: + """ + Transpile a sass or scss file into a css file + + :param source: Full text source of the template + :param name: Name of the template + :param filename: Path to the template + :raises CompileError: When the template cannot be parsed + """ + + if (tpl_name := filename or name) is None: + return source + + if (ext := splitext(tpl_name)[1]) not in self._exts: + return source + + return sass.compile( # type: ignore[no-any-return] + string = source, + output_style = self.output_style.value, + indented = ext == ".sass" + ) + + class Template(Environment): def __init__(self, *search: str | Path, @@ -38,9 +96,12 @@ class Template(Environment): super().__init__( loader = self.search, - extensions = [HamlishExtension], lstrip_blocks = True, - trim_blocks = True + trim_blocks = True, + extensions = [ + HamlishExtension, + SassExtension + ] ) for path in search: diff --git a/pyproject.toml b/pyproject.toml index 0280445..d3e3b72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "jinja2-haml == 0.3.5", "libsass == 0.23.0", "multidict == 6.0.5", + "multipart == 0.2.4", "python-multipart == 0.0.9", "pyyaml == 6.0.1", "uvicorn == 0.29.0"