add documentation for some classes and functions

This commit is contained in:
Izalia Mae 2024-04-19 20:26:44 -04:00
parent a8e7813318
commit 2a04991e5c
15 changed files with 438 additions and 337 deletions

View file

@ -3,22 +3,18 @@ __version__ = "0.1.0"
from .application import Application, StaticHandler
from .client import Client
from .enums import HttpStatus, SassOutputStyle
from .enums import HttpStatus, PridePalette, SassOutputStyle
from .error import HttpError
from .request import Request
from .response import Response, FileResponse, TemplateResponse
from .runner import Runner, UvicornRunner, GranianRunner
from .signal import Signal
from .template import SassExtension, Template
from .misc import (
Color,
Cookie,
StateProxy,
Stream,
convert_to_bytes,
convert_to_string,
is_loop_running
Stream
)
from .router import (
@ -44,5 +40,4 @@ from .misc import ReaderFunction, WriterFunction
from .request import AsgiVersionType, ScopeType
from .router import RouteHandler
from .runner import BackgroundTask
from .signal import SignalCallback
from .template import TemplateContextCallback

View file

@ -2,8 +2,9 @@ from __future__ import annotations
import traceback
from blib import Signal
from collections.abc import Callable
from functools import lru_cache
from mimetypes import guess_type
from os.path import normpath
from pathlib import Path
from typing import Any, Generic, TypeVar
@ -14,7 +15,6 @@ 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, TemplateContextCallback
@ -28,6 +28,8 @@ APPLICATIONS: dict[str, Application] = {} # type: ignore[type-arg]
class Application(Generic[R, RT, AT]):
"Represents an ASGI application"
def __init__(self,
name: str,
template_search: list[Path | str] | None = None,
@ -36,34 +38,65 @@ class Application(Generic[R, RT, AT]):
request_class: type[R] = Request, # type: ignore[assignment]
request_state_class: type[RT] = StateProxy, # type: ignore[assignment]
app_state_class: type[AT] = StateProxy) -> None: # type: ignore[assignment]
"""
Create a new ``Application``
:param name: Identifier for the application. This is used for the ``Server`` header
and adding routes.
:param template_search: Directories to search when locating a template
:param template_env: Context data to include with every template render
:param template_context: Function that gets called on every template render
:param request_class: Class to use for requests
:param request_state_class: Class to use for the :attr:`basgi.Request.state` property
:param app_state_class: Class to use for the :attr:`basgi.Application.state` property
"""
if name in APPLICATIONS:
raise ValueError(f"Application with name '{name}' already exists")
APPLICATIONS[name] = self
self.name: str = name
"Identifier for the application"
self.state: AT = app_state_class({})
"Data to be stored with the application"
self.request_class: type[R] = request_class
"Class to use for requests"
self.request_state_class: type[RT] = request_state_class
"Class to use for the :attr:`basgi.Request.state` property"
# Not sure how to properly annotate `Exception` here, so just ignore the warnings
self.error_handlers: dict[type[ExceptionType], ExceptionCallback] = { # type: ignore
HttpError: self.handle_http_error
HttpError: self._handle_http_error
}
"Functions to be called on specific errors"
self.router: Router = get_router(self.name)
self.router.trim_last_slash = True
"Contains all of the routes the application will handle"
self.client: Client = Client()
"Customized httpx client"
self.template: Template = Template(
*(template_search or []),
context_function = template_context,
**(template_env or {}))
APPLICATIONS[name] = self
**(template_env or {})
)
"Template environment"
@classmethod
def get(cls: type[Application[R, RT, AT]], name: str) -> Application[R, RT, AT]:
@staticmethod
def get(name: str) -> Application[R, RT, AT]:
"""
Get an application by its name
:param name: Identifier of the application
:raises KeyError: If an application by the specified name cannot be found
"""
return APPLICATIONS[name]
@ -94,7 +127,7 @@ class Application(Generic[R, RT, AT]):
await self.on_request.handle_emit(request)
except Exception as error:
response = self.handle_error(request, error)
response = self._handle_error(request, error)
try:
match = self.router(request.path, request.method)
@ -105,13 +138,13 @@ class Application(Generic[R, RT, AT]):
raise HttpError(500, "Empty response")
except Exception as error:
response = self.handle_error(request, error)
response = self._handle_error(request, error)
try:
await self.on_response.handle_emit(request, response)
except Exception as error:
response = self.handle_error(request, error)
response = self._handle_error(request, error)
if not response.headers.get("Server"):
response.headers["Server"] = self.name
@ -122,34 +155,79 @@ class Application(Generic[R, RT, AT]):
@Signal(5.0)
async def on_request(self, request: Request) -> None:
pass
"""
:class:`blib.Signal` that gets emitted just after the request is created
:param application: The application that emit the signal
:param request: The newly-created request
"""
...
@Signal(5.0)
async def on_response(self, request: Request, response: Response) -> None:
pass
"""
:class:`blib.Signal` that gets emitted just after the route handler is called
:param application: The application that emit the signal
:param request: The request
:param response: Response from the route handler
"""
...
@Signal(5.0)
async def on_startup(self) -> None:
pass
"""
:class:`blib.Signal` that gets emitted on server start
:param application: The application that emit the signal
"""
...
@Signal(5.0)
async def on_shutdown(self) -> None:
pass
"""
:class:`blib.Signal` that gets emitted just before server stop
:param application: The application that emit the signal
"""
...
def add_route(self, method: str, path: str, handler: RouteHandler) -> None:
"""
Add a route handler
:param method: HTTP method the route will handle
:param path: Virtual HTTP path the route will handle
:param handler: Function to call when the route is requested
"""
self.router.bind(handler, path, methods = [method])
def add_static(self, path: str, location: Path | str, cached: bool = False) -> None:
"""
Add a route handler that servers a directory. If a directory is requested and
``index.html`` exists in that directory, it will be served instead.
:param path: Virtual HTTP path the route will handle
:param location: Directory to use for serving files
:param cached: Store all requested files in memory after being read from the disk for
the first time
"""
handler = StaticHandler(path, location, cached)
self.add_route("GET", handler.path, handler)
def print_access_log(self, request: Request, response: Response) -> None:
"""
Prints the request line to the terminal. Override this in a sub-class to customize it.
"""
message = "{}: {} \"{} {}\" {} {} \"{}\"".format(
"INFO",
request.headers.get(
@ -169,32 +247,48 @@ class Application(Generic[R, RT, AT]):
print(message, flush = True)
def handle_error(self, request: Request, error: Exception) -> Response:
def _handle_error(self, request: Request, error: Exception) -> Response:
try:
return self.error_handlers[type(error)](request, error)
except KeyError:
traceback.print_exception(error)
return Response(500, "Internal Server Error")
except Exception:
traceback.print_exc()
return Response(500, "Internal Server Error")
def handle_http_error(self, request: Request, error: HttpError) -> Response:
def _handle_http_error(self, request: Request, error: HttpError) -> Response:
return Response.new_from_http_error(error)
class StaticHandler:
"Provides a handler for a directory of static files"
def __init__(self, path: str, location: Path | str, cached: bool) -> None:
"""
Create a new static file handler
:param path: Virtual HTTP path the route will handle
:param location: Directory to use for serving files
:param cached: Store all requested files in memory after being read from the disk for
the first time
"""
if isinstance(location, str):
location = Path(location)
self.path: str = path.rstrip("/") + "/{filepath:.*}"
"Path regex used to determine if a path is handled or not"
self.location: Path = Path(location).expanduser().resolve()
"Directory to use for serving files"
self.cached: bool = cached
"Whether or not all requested files will be stored in memory"
self.cache: dict[str, Response] = {}
"Currently cached responses"
if not self.location.exists():
raise FileNotFoundError(self.location)
@ -204,27 +298,32 @@ class StaticHandler:
async def __call__(self, request: Request) -> Response:
filepath = request.params["filepath"]
"""
Function that gets called for incoming requests
if self.cached:
return await self.handle_call_cached(request, filepath)
:param request: The incoming request
:param filepath: Relative path of the requested file
:raises HttpError: If the file on disk cannot be found
"""
return await self.handle_call(request, filepath)
filepath = normpath(request.params["filepath"].lstrip("/"))
if self.cached and filepath in self.cache:
return self.cache[filepath]
async def handle_call(self, request: Request, filepath: str) -> Response:
filepath = normpath(filepath)
path = self.location.joinpath(filepath.lstrip("/"))
path = self.location.joinpath(filepath)
if path.is_dir():
path = path.joinpath("index.html")
if not path.is_file():
raise HttpError(404, str(filepath))
raise HttpError(404, filepath)
if self.cached:
with path.open("rb") as fd:
response = Response(200, fd.read(), mimetype = guess_type(path)[0])
self.cache[filepath] = response
return response
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)

View file

@ -1,6 +1,6 @@
import re
from aputils import IntEnum, StrEnum
from blib import Enum, IntEnum, StrEnum
CAPITAL = re.compile("[A-Z][^A-Z]")
@ -86,6 +86,25 @@ class HttpStatus(IntEnum):
return " ".join(CAPITAL.findall(self.name))
class PridePalette(tuple[str, ...], Enum):
LGBT = ("#9400D3", "#4B0082", "#0000FF", "#00FF00", "#FFFF00", "#FF7F00", "#FF0000")
LESBIAN = ("#D52D00", "#EF7627", "#FF9A56", "#FFFFFF", "#D162A4", "#B55690", "#A30262")
BI = ("#D60270", "#9B4F96", "#0038A8")
GAY = ("#078D70", "#26CEAA", "#98E8C1", "#FFFFFF", "#7BADE2", "#5049CC", "#3D1A78")
PANSEXUAL = ("#FF218C", "#FFD800", "#21B1FF")
ASEXUAL = ("#000000", "#A3A3A3", "#FFFFFF", "#800080")
AROMANTIC = ("#3DA542", "#A7D379", "#FFFFFF", "#A9A9A9", "#000000")
TRANS = ("#55CDFC", "#F7A8B8", "#FFFFFF")
TRANS_BLACK = ("#55CDFC", "#F7A8B8", "#000000")
TRANSMASC = ("#FF8ABD", "#CDF5FE", "#9AEBFF", "#74DFFF")
NONBINARY = ("#FCF434", "#FFFFFF", "#9C59D1", "#2C2C2C")
# aliases
ACE = ASEXUAL
ARO = AROMANTIC
ENBY = NONBINARY
class SassOutputStyle(StrEnum):
NESTED = "nested"
EXPANDED = "expanded"

View file

@ -1,7 +1,4 @@
import asyncio
import json
from aputils import HttpDate, JsonBase
from aputils import HttpDate
from colorsys import rgb_to_hls, hls_to_rgb
from datetime import datetime, timedelta
from functools import cached_property
@ -10,7 +7,6 @@ from typing import Any, Literal, Self
from collections.abc import (
Awaitable,
Callable,
Iterable,
Iterator,
ItemsView,
KeysView,
@ -19,116 +15,50 @@ from collections.abc import (
ValuesView
)
from .enums import PridePalette
ReaderFunction = Callable[..., Awaitable[dict[str, Any]]]
WriterFunction = Callable[[dict[str, Any]], Awaitable[None]]
PRIDE_COLORS = {
"lgbt": ["#9400D3", "#4B0082", "#0000FF", "#00FF00", "#FFFF00", "#FF7F00", "#FF0000"],
"lesbian": ["#D52D00", "#EF7627", "#FF9A56", "#FFFFFF", "#D162A4", "#B55690", "#A30262"],
"bi": ["#D60270", "#9B4F96", "#0038A8"],
"gay": ["#078D70", "#26CEAA", "#98E8C1", "#FFFFFF", "#7BADE2", "#5049CC", "#3D1A78"],
"pan": ["#FF218C", "#FFD800", "#21B1FF"],
"asexual": ["#000000", "#A3A3A3", "#FFFFFF", "#800080"],
"aromantic": ["#3DA542", "#A7D379", "#FFFFFF", "#A9A9A9", "#000000"],
"trans": ["#55CDFC", "#F7A8B8", "#FFFFFF"],
"trans-black": ["#55CDFC", "#F7A8B8", "#000000"],
"transmasc": ["#FF8ABD", "#CDF5FE", "#9AEBFF", "#74DFFF"],
"non-binary": ["#FCF434", "#FFFFFF", "#9C59D1", "#2C2C2C"]
}
def convert_to_bytes(value: Any, encoding: str = "utf-8") -> bytes:
"""
Convert an object to :class:`bytes`
:param value: Object to be converted
:param encoding: Character encoding to use if the object is a string or gets converted to
one in the process
:raises TypeError: If the object cannot be converted
"""
if isinstance(value, bytes):
return value
try:
return convert_to_string(value).encode(encoding)
except TypeError:
raise TypeError(f"Cannot convert '{type(value).__name__}' into bytes") from None
def convert_to_string(value: Any, encoding: str = 'utf-8') -> str:
"""
Convert an object to :class:`str`
:param value: Object to be converted
:param encoding: Character encoding to use if the object is a :class:`bytes` object
"""
if value is None:
return ''
if isinstance(value, bytes):
return value.decode(encoding)
if isinstance(value, bool):
return str(value)
if isinstance(value, str):
return value
if isinstance(value, JsonBase):
return value.to_json()
if isinstance(value, (dict, list, tuple, set)):
return json.dumps(value)
if isinstance(value, (int, float)):
return str(value)
raise TypeError(f'Cannot convert "{type(value).__name__}" into a string') from None
def is_loop_running() -> bool:
"Check if an event loop is running in the current thread"
try:
loop = asyncio.get_event_loop()
if not loop:
return False
return loop.is_running()
except Exception:
return False
class Color(str):
"Represents an HTML color value"
def __new__(cls, color: str) -> Self:
if not isinstance(color, str):
raise TypeError("Color must be a valid hex string")
"""
Create a new ``Color`` object
if not color.startswith("#"):
color = f"#{str(color)}"
:param color: Hex color string of 3 or 6 characters (``#`` character optional)
"""
if len(color) == 4:
color = f"#{color[1]*2}{color[2]*2}{color[3]*2}"
color = color.lstrip("#")
elif len(color) != 7:
if len(color) == 3:
color = f"{color[1]*2}{color[2]*2}{color[3]*2}"
elif len(color) != 6:
raise TypeError("Color must be 3 or 6 character hex string")
return str.__new__(cls, color)
return str.__new__(cls, "#" + color)
def __repr__(self) -> str:
return f"Color({self})"
return f"Color('{self}')"
@classmethod
def from_rgb(cls: type[Self], red: int, green: int, blue: int) -> Self:
"""
Create a new ``Color`` object from red, green, and blue values. The values must be
between ``0`` and ``255``
:param red: Red color value
:param green: Green color value
:param blue: Blue color value
"""
values = [red, green, blue]
for value in values:
@ -140,6 +70,10 @@ class Color(str):
@classmethod
def from_hsl(cls: type[Self], hue: int, saturation: int, luminance: int) -> Self:
"""
Create a new ``Color`` object from hue, saturation, and luminance values
"""
hsl_values: list[int | float] = [hue, saturation, luminance]
for idx, value in enumerate(hsl_values):
@ -155,23 +89,14 @@ class Color(str):
@classmethod
def new_pride_palette(cls: type[Self], flag: str) -> Iterable[Self]:
"Returns a `tuple` of `Color` objects which represents a pride flag color palette"
def new_pride_palette(cls: type[Self], flag: str) -> tuple[Self, ...]:
"Returns multiple ``Color`` objects which represents a pride flag color palette"
if flag == "ace":
flag = "asexual"
elif flag == "aro":
flag = "aromantic"
elif flag in ["enby", "nonbinary"]:
flag = "non-binary"
return tuple(cls(color) for color in PRIDE_COLORS[flag])
return tuple(cls(color) for color in PridePalette.parse(flag.replace("-", "")))
@staticmethod
def parse_multi(multiplier: int) -> float:
def _parse_multi(multiplier: int) -> float:
if multiplier >= 100:
return 1
@ -183,11 +108,15 @@ class Color(str):
@cached_property
def rgb(self) -> tuple[int, int, int]:
"Get the color as a tuple of red, green, and blue levels"
return (self.red, self.green, self.blue)
@cached_property
def hsl(self) -> tuple[int, int, int]:
"Get the color as a tuple of hue, saturation, and luminance levels"
rgb = [value / 255 for value in self.rgb]
values = [int(value * 255) for value in rgb_to_hls(*rgb)]
return (values[0], values[2], values[1])
@ -195,48 +124,70 @@ class Color(str):
@cached_property
def red(self) -> int:
"Get the red color level"
return int(self[1:3], 16)
@cached_property
def green(self) -> int:
"Get the green color level"
return int(self[3:5], 16)
@cached_property
def blue(self) -> int:
"Get the blue color level"
return int(self[5:7], 16)
@cached_property
def hue(self) -> int:
"Get the hue level"
return self.hsl[0]
@cached_property
def saturation(self) -> int:
"Get the saturation level"
return self.hsl[1]
@cached_property
def lightness(self) -> int:
def luminance(self) -> int:
"Get the luminance level"
return self.hsl[2]
def alter(self, action: str, multiplier: int) -> Self:
def alter(self,
action: Literal["lighten", "darken", "saturate", "desaturate"],
multiplier: int) -> Self:
"""
Change the lightness or saturation of the color
:param action: Modification action to take on the color
:param multiplier: Amount to multiply by for the effect. Any value outside
0 - 100 will be changed to the nearest valid value.
"""
hue, saturation, luminance = self.hsl
if action == "lighten":
luminance += int((255 - luminance) * Color.parse_multi(multiplier))
luminance += int((255 - luminance) * Color._parse_multi(multiplier))
elif action == "darken":
luminance -= int(luminance * Color.parse_multi(multiplier))
luminance -= int(luminance * Color._parse_multi(multiplier))
elif action == "saturate":
saturation += int((255 - saturation) * Color.parse_multi(multiplier))
saturation += int((255 - saturation) * Color._parse_multi(multiplier))
elif action == "desaturate":
saturation -= int(saturation * Color.parse_multi(multiplier))
saturation -= int(saturation * Color._parse_multi(multiplier))
else:
raise ValueError(f"Invalid action: {action}")
@ -245,22 +196,57 @@ class Color(str):
def lighten(self, multiplier: int) -> Self:
"""
Alias of ``Color.alter('lighten', multiplier)``
:param multiplier: Amount to multiply by for the effect. Any value outside
0 - 100 will be changed to the nearest valid value.
"""
return self.alter("lighten", multiplier)
def darken(self, multiplier: int) -> Self:
"""
Alias of ``Color.alter('darken', multiplier)``
:param multiplier: Amount to multiply by for the effect. Any value outside
0 - 100 will be changed to the nearest valid value.
"""
return self.alter("darken", multiplier)
def saturate(self, multiplier: int) -> Self:
"""
Alias of ``Color.alter('saturate', multiplier)``
:param multiplier: Amount to multiply by for the effect. Any value outside
0 - 100 will be changed to the nearest valid value.
"""
return self.alter("saturate", multiplier)
def desaturate(self, multiplier: int) -> Self:
"""
Alias of ``Color.alter('desaturate', multiplier)``
:param multiplier: Amount to multiply by for the effect. Any value outside
0 - 100 will be changed to the nearest valid value.
"""
return self.alter("desaturate", multiplier)
def rgba(self, opacity: int) -> str:
"""
Return the color as a CSS ``rgba`` value
:param opacity: Opacity level to apply to the color. Any value outside
0 - 100 will be changed to the nearest valid value.
"""
if 0 < opacity > 100:
raise ValueError("Opacity must anywhere from 0 to 100")
@ -270,6 +256,9 @@ class Color(str):
class Cookie:
"Represents an HTTP cookie"
def __init__(self,
key: str,
value: str | None,
@ -280,23 +269,60 @@ class Cookie:
secure: bool = False,
http_only: bool = False,
same_site: Literal["lax", "strict", "none"] | None = "lax") -> None:
"""
Create a new cookie
:param key: Name of the cookie
:param value: Value of the cookie
:param max_age: Maximum amount of time for the cookie to be valid
:param expires: Date the cookie expires
:param path: Path base the cookie should be used on
:param domain: Domain the cookie should be used on
:param secure: Ensure the cookie is only used with ``HTTPS`` connections
:param http_only: Prevent on-page javascript from accessing the cookie
:param same_site: Make sure the cookie stays on the same website
"""
if isinstance(max_age, int):
max_age = timedelta(seconds = min(0, max_age))
self.key: str = key
"Name of the cookie"
self.value: str | None = value or None
"Value of the cookie"
self.max_age: timedelta | None = max_age
"Maximum amount of time for the cookie to be valid"
self.expires: HttpDate | None = HttpDate.parse(expires) if expires is not None else None
"Date the cookie expires"
self.path: str = path
"Path base the cookie should be used on"
self.domain: str | None = domain
self.same_site: Literal["lax", "strict", "none"] = same_site or "none"
"Domain the cookie should be used on"
self.secure: bool = secure
"Ensure the cookie is only used with ``HTTPS`` connections"
self.http_only: bool = False
"Prevent on-page javascript from accessing the cookie"
self.same_site: Literal["lax", "strict", "none"] = same_site or "none"
"Make sure the cookie stays on the same website"
@classmethod
def from_dict(cls: type[Self], data: dict[str, Any]) -> Self:
"""
Create a new cookie from a :class:`dict`. The keys can be HTTP cookie keys or
``Cookie`` property names.
:param data: Dictionary of cookie properties
"""
same_site = data.get("same_site", data.get("samesite", "none")).lower()
if same_site not in {"lax", "strict", "none"}:
@ -319,6 +345,12 @@ class Cookie:
@classmethod
def parse(cls: type[Self], raw_data: str) -> Self:
"""
Read a raw cookie string and create a new ``Cookie`` object
:param raw_data: Raw cookie data
"""
lines = tuple(line.strip() for line in raw_data.split(";"))
ckey, _, cvalue = lines[0].partition("=")
@ -338,14 +370,20 @@ class Cookie:
def copy(self) -> Self:
"Create a copy of the cookie"
return type(self).from_dict(self.to_dict())
def set_deleted(self) -> None:
"Set the expires property to a date in the past"
self.expires = HttpDate.parse("Thu, 01 Jan 1970 00:00:00 GMT")
def to_dict(self) -> dict[str, Any]:
"Return the cookie as a dict"
keys = (
"key", "value", "max_age", "expires", "path",
"domain", "same_site", "secure", "http_only"
@ -355,6 +393,8 @@ class Cookie:
def to_string(self) -> str:
"Convert the cookie to a raw cookie string"
values = [
f"{self.key}={self.value or ''}",
f"path={self.path}",
@ -380,7 +420,16 @@ class Cookie:
class StateProxy(MutableMapping[str, Any]):
"Proxy object for the ASGI state value"
def __init__(self, state: dict[str, str]) -> None:
"""
Create a new state proxy object
:param state: A dict to proxy (usually the ASGI state)
"""
self._state: dict[str, str] = state
@ -430,29 +479,55 @@ class StateProxy(MutableMapping[str, Any]):
def get(self, key: str, default: Any = None) -> Any:
"""
Get a value or return the default if it does not exist
:param key: Name of the value to get
:param default: Value to return if the key does not exist
"""
return self._state.get(key, default)
def items(self) -> ItemsView[str, Any]:
"Return all state items as key/value pairs"
return self._state.items()
def keys(self) -> KeysView[str]:
"Return all keys in the state"
return self._state.keys()
def set(self, key: str, value: Any) -> None:
"""
Set a state value
:param key: Name of the value to set
:param value: Data to set
"""
self._state[key] = value
def values(self) -> ValuesView[Any]:
"Return all values in the state"
return self._state.values()
class Stream:
"Contains the reader and writer functions. Can be used as an iterator for fetching data chunks."
def __init__(self, reader: ReaderFunction, writer: WriterFunction) -> None:
self.reader = reader
self.writer = writer
self.reader: ReaderFunction = reader
"Function for reading data from a client"
self.writer: WriterFunction = writer
"Function for writing data to a client"
self._sent_headers: bool = False
self._sent_body: bool = False
self.__next: bool = True
@ -474,10 +549,14 @@ class Stream:
async def close(self) -> None:
"Disconnect from the client"
await self.writer({"type": "http.disconnect"})
async def read(self) -> bytes:
"Read the whole request body"
body = b""
while True:
@ -491,11 +570,20 @@ class Stream:
async def read_chunk(self) -> tuple[bytes, bool]:
"Read a chunk of data from the request and return a tuple of (``body chunk``, ``more body``)"
data = await self.reader()
return data.get("body", b""), data.get("more_body", False)
async def write_headers(self, status: int, headers: Sequence[tuple[bytes, bytes]]) -> None:
"""
Write the response headers. If this was already called, nothing happens.
:param status: HTTP status to respond with
:param headers: A sequence of HTTP header values to be sent with the response
"""
if self._sent_headers:
return
@ -505,8 +593,20 @@ class Stream:
"headers": headers
})
self._sent_headers = True
async def write_body(self, data: bytes, eof: bool = False) -> None:
"""
Send body data to the client. If an eof was already sent, this does nothing.
:param data: Raw data to send to the client
:param eof: Send an ``EOF`` to indicate the response is done.
"""
if not self._sent_headers:
raise RuntimeError("Response headers have not been sent yet")
if self._sent_body:
return
@ -515,3 +615,6 @@ class Stream:
"body": data,
"more_body": eof
})
if eof:
self._sent_body = True

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import os
from aputils import HttpDate, JsonBase
from blib import convert_to_bytes
from collections.abc import Sequence
from datetime import datetime, timedelta
from mimetypes import guess_type
@ -14,7 +15,7 @@ from urllib.parse import quote
from .enums import HttpStatus
from .error import HttpError
from .misc import Cookie, Stream, convert_to_bytes
from .misc import Cookie, Stream
from .request import Request
@ -268,7 +269,7 @@ class TemplateResponse(Response):
status: HttpStatus | int = HttpStatus.Ok,
mimetype: str | None = None,
headers: dict[str, Any] | None = None,
pretty_print: bool = False) -> None:
pprint: bool = False) -> None:
"""
Create a new ``TemplateResponse`` object
@ -283,12 +284,17 @@ class TemplateResponse(Response):
Response.__init__(self, status, b"", mimetype or self.detect_mimetype(name), headers)
self.name: str = name
self.pretty_print: bool = pretty_print
self.pprint: bool = pprint
self.context: dict[str, Any] = context or {}
def detect_mimetype(self, path: str) -> str:
"Return the mimetype of the template based on the file extension"
@staticmethod
def detect_mimetype(path: str) -> str:
"""
Return the mimetype of the template based on the file extension
:param path: Path to detect the mimetype of
"""
_, ext = os.path.splitext(path)
@ -306,7 +312,7 @@ class TemplateResponse(Response):
"request": request,
**self.context
}
text = request.app.template.render(self.name, context, self.pretty_print)
text = request.app.template.render(self.name, context, self.pprint)
self.body = text.encode("utf-8")
await Response.send(self, stream, request)

View file

@ -1,156 +0,0 @@
import asyncio
import inspect
import traceback
from collections.abc import Awaitable, Callable
from typing import Any, Self
from .misc import is_loop_running
SignalCallback = Callable[..., Awaitable[bool | None]]
MainSignalCallback = Callable[..., Awaitable[None]]
class Signal(list[SignalCallback]):
"Allows a series of callbacks to get called via async. Use as a decorator for the base function."
def __init__(self, timeout: float | int = 5.0):
"""
:param timeout: Time in seconds to wait before cancelling the callback
"""
self.timeout: float | int = timeout
self.callback: MainSignalCallback | None = None
self.object: Any = None
def __get__(self, obj: Any, objtype: type[Any]) -> Self:
if obj and not self.object:
self.object = obj
return self
def __call__(self, callback: MainSignalCallback) -> Self:
if not inspect.iscoroutinefunction(callback):
raise RuntimeError(f"Not a coroutine: {callback.__name__}")
self.callback = callback
self.__doc__ = callback.__doc__
self.__annotations__ = callback.__annotations__
return self
def append(self, value: SignalCallback) -> None:
"""
Add a callback
:param value: Callback
"""
self.connect(value)
def remove(self, value: SignalCallback) -> None:
"""
Remove a callback
:param value: Callback
"""
self.disconnect(value)
def emit(self, *args: Any, **kwargs: Any) -> None:
"""
Call all of the callbacks in the order they were added as well as the associated
function.
If any callback returns `True`, all other callbacks get skipped.
:param args: Positional arguments to pass to all of the callbacks
:param kwargs: Keyword arguments to pass to all of the callbacks
"""
if not is_loop_running():
raise RuntimeError("Event loop is not running")
asyncio.create_task(self.handle_emit(*args, **kwargs))
def connect(self, callback: SignalCallback) -> SignalCallback:
"""
Add a function to the list of callbacks. Can be used as a decorator.
:param callback: A callable or coroutine
"""
if callback not in self:
list.append(self, callback)
return callback
def disconnect(self, callback: SignalCallback) -> None:
"""
Remove a function from the list of callbacks
:param callback: A callable or coroutine
"""
if not self.callback:
# oh boy something really goofed
return
try:
list.remove(self, callback)
except ValueError:
cbname = callback.__name__
signame = self.callback.__name__
print(f"WARNING: '{cbname}' was not connted to signal '{signame}'")
async def handle_emit(self, *args: Any, **kwargs: Any) -> None:
"""
This gets called by :meth:`Signal.emit` as an :class:`asyncio.Task`.
:param args: Positional arguments to pass to all of the callbacks
:param kwargs: Keyword arguments to pass to all of the callbacks
"""
if not self.callback:
# oh boy something really goofed
return
for callback in self:
if await self.handle_callback(callback, *args, **kwargs):
break
await self.handle_callback(self.callback, *args, **kwargs)
async def handle_callback(self,
callback: Callable[..., Awaitable[bool | None]],
*args: Any,
**kwargs: Any) -> bool | None:
try:
# asyncio.timeout and asyncio.wait_for complain, so need to find a new way
# async with asyncio.timeout(self.timeout):
if args and args[0] == self.object:
return await callback(*args, **kwargs)
else:
return await callback(self.object, *args, **kwargs)
except TimeoutError:
print(f"Callback '{callback.__name__}' timed out")
return True
except Exception:
traceback.print_exc()
return True

View file

@ -48,7 +48,7 @@ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
intersphinx_mapping = {
"python": (f"https://docs.python.org/{pyversion}", None),
"aputils": ("https://docs.barkshark.xyz/aputils", None),
"bsql": ("https://docs.barkshark.xyz/barkshark-sql", None),
"blib": ("https://docs.barkshark.xyz/blib", None),
"gemi": ("https://docs.barkshark.xyz/gemi", None),
"hamlish-jinja": ("https://docs.barkshark.xyz/hamlish-jinja", None),
"jinja2": ("https://jinja.palletsprojects.com/en/3.1.x/", None)

View file

@ -5,10 +5,6 @@ Application
:members:
:show-inheritance:
.. autoclass:: basgi.Signal
:members:
:show-inheritance:
.. autoclass:: basgi.Stream
.. autoclass:: basgi.StaticHandler
:members:
:show-inheritance:

View file

@ -7,6 +7,12 @@ Enums
:undoc-members:
:exclude-members: __new__
.. autoclass:: basgi.PridePalette
:members:
:show-inheritance:
:undoc-members:
:exclude-members: __new__
.. autoclass:: basgi.SassOutputStyle
:members:
:show-inheritance:

View file

@ -7,8 +7,6 @@ Application
:class:`basgi.Application`
:class:`basgi.Signal`
:class:`basgi.Stream`

View file

@ -2,10 +2,17 @@ Messages
========
Request
-------
.. autoclass:: basgi.Request
:members:
:show-inheritance:
Response
--------
.. autoclass:: basgi.Response
:members:
:show-inheritance:
@ -17,3 +24,11 @@ Messages
.. autoclass:: basgi.TemplateResponse
:members:
:show-inheritance:
Helpers
-------
.. autoclass:: basgi.Cookie
:members:
:show-inheritance:

24
docs/src/api/misc.rst Normal file
View file

@ -0,0 +1,24 @@
Misc
====
.. autoclass:: basgi.Color
:members:
:show-inheritance:
.. autoclass:: basgi.SassExtension
:members:
:show-inheritance:
:exclude-members: __init__
.. autoclass:: basgi.StateProxy
:members:
:show-inheritance:
.. autoclass:: basgi.Stream
:members:
:show-inheritance:
:exclude-members: __init__
.. autoclass:: basgi.Template
:members:
:show-inheritance:

View file

@ -1,4 +0,0 @@
Signal
======

View file

@ -9,7 +9,7 @@ subtrees:
- file: src/api/runners.rst
- file: src/api/messages.rst
- file: src/api/router.rst
- file: src/api/template.rst
- file: src/api/misc.rst
- file: src/api/enums.rst
- file: src/api/exceptions.rst
- url: https://git.barkshark.xyz/barkshark/basgi

View file

@ -36,6 +36,7 @@ classifiers = [
requires-python = ">= 3.11"
dependencies = [
"activitypub-utils == 0.2.3",
"barkshark-lib == 0.1.1",
"gemi-python == 0.1.1",
"http-router == 4.1.2",
"httpx[http2] == 0.27.0",
@ -98,6 +99,5 @@ disallow_untyped_decorators = true
warn_redundant_casts = true
warn_unreachable = true
warn_unused_ignores = true
follow_imports = "silent"
strict = true
implicit_reexport = true