This commit is contained in:
Izalia Mae 2024-04-19 22:10:55 -04:00
parent 2a04991e5c
commit d48cc7024a
11 changed files with 101 additions and 272 deletions

View file

@ -3,8 +3,7 @@ __version__ = "0.1.0"
from .application import Application, StaticHandler
from .client import Client
from .enums import HttpStatus, PridePalette, SassOutputStyle
from .error import HttpError
from .enums import PridePalette, SassOutputStyle
from .request import Request
from .response import Response, FileResponse, TemplateResponse
from .runner import Runner, UvicornRunner, GranianRunner

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import traceback
from blib import Signal
from blib import HttpError, Signal
from collections.abc import Callable
from mimetypes import guess_type
from os.path import normpath
@ -10,7 +10,6 @@ from pathlib import Path
from typing import Any, Generic, TypeVar
from .client import Client
from .error import HttpError
from .misc import StateProxy, Stream, ReaderFunction, WriterFunction
from .request import Request, ScopeType
from .response import FileResponse, Response

View file

@ -1,12 +1,22 @@
import aputils
import httpx
from typing import TypeVar
from aputils import (
AlgorithmType,
JsonBase,
Message,
Nodeinfo,
Webfinger,
WellKnownNodeinfo,
Signer,
register_signer
)
from . import __version__
T = TypeVar("T", bound = aputils.JsonBase)
T = TypeVar("T", bound = JsonBase)
class Client:
@ -38,16 +48,16 @@ class Client:
async def request(self,
method: str,
url: str,
body: aputils.JsonBase | None = None,
body: JsonBase | None = None,
headers: dict[str, str] | None = None,
signer: aputils.Signer | None = None,
algorithm: aputils.AlgorithmType | str = aputils.AlgorithmType.RSASHA256,
signer: Signer | None = None,
algorithm: AlgorithmType | str = AlgorithmType.RSASHA256,
stream: bool = False,
follow_redirects: bool = False) -> httpx.Response:
url = url.split("#", 1)[0]
if body is not None and not isinstance(body, aputils.JsonBase):
if body is not None and not isinstance(body, JsonBase):
raise TypeError("body must be a JsonBase object")
data = body.to_json() if body else None
@ -69,22 +79,22 @@ class Client:
async def fetch_json(self,
url: str,
cls: type[T] | None = None,
signer: aputils.Signer | None = None) -> T:
signer: Signer | None = None) -> T:
message_class: type[T] = aputils.JsonBase if cls is None else cls # type: ignore
message_class: type[T] = JsonBase if cls is None else cls # type: ignore
headers = {
"Accept": "application/" + ("activity+json" if cls is aputils.Message else "json")
"Accept": "application/" + ("activity+json" if cls is Message else "json")
}
response = await self.get(url, headers, signer)
return message_class.parse(response.json())
async def fetch_nodeinfo(self, domain: str) -> aputils.Nodeinfo:
async def fetch_nodeinfo(self, domain: str) -> Nodeinfo:
wk_nodeinfo = await self.fetch_json(
f"https://{domain}/.well-known/nodeinfo",
aputils.WellKnownNodeinfo
WellKnownNodeinfo
)
try:
@ -93,19 +103,19 @@ class Client:
except KeyError:
url = wk_nodeinfo.v20
return await self.fetch_json(url, aputils.Nodeinfo)
return await self.fetch_json(url, Nodeinfo)
async def fetch_webfinger(self, username: str, domain: str) -> aputils.Webfinger:
async def fetch_webfinger(self, username: str, domain: str) -> Webfinger:
url = f"https://{domain}/.well-known/webfinger?resource=acct:{username}@{domain}"
return await self.fetch_json(url, aputils.Webfinger)
return await self.fetch_json(url, Webfinger)
async def get(self,
url: str,
headers: dict[str, str] | None = None,
signer: aputils.Signer | None = None,
algorithm: aputils.AlgorithmType | str = aputils.AlgorithmType.RSASHA256,
signer: Signer | None = None,
algorithm: AlgorithmType | str = AlgorithmType.RSASHA256,
stream: bool = False,
follow_redirects: bool = False) -> httpx.Response:
@ -117,8 +127,8 @@ class Client:
async def head(self,
url: str,
headers: dict[str, str] | None = None,
signer: aputils.Signer | None = None,
algorithm: aputils.AlgorithmType | str = aputils.AlgorithmType.RSASHA256,
signer: Signer | None = None,
algorithm: AlgorithmType | str = AlgorithmType.RSASHA256,
follow_redirects: bool = False) -> httpx.Response:
return await self.request(
@ -128,10 +138,10 @@ class Client:
async def post(self,
url: str,
body: aputils.JsonBase,
body: JsonBase,
headers: dict[str, str] | None = None,
signer: aputils.Signer | None = None,
algorithm: aputils.AlgorithmType | str = aputils.AlgorithmType.RSASHA256,
signer: Signer | None = None,
algorithm: AlgorithmType | str = AlgorithmType.RSASHA256,
stream: bool = False,
follow_redirects: bool = False) -> httpx.Response:
@ -140,11 +150,11 @@ class Client:
)
@aputils.register_signer(httpx.Request)
@register_signer(httpx.Request)
def handle_httpx_sign(
signer: aputils.Signer,
signer: Signer,
request: httpx.Request,
algorithm: aputils.AlgorithmType) -> httpx.Request:
algorithm: AlgorithmType) -> httpx.Request:
headers = signer.sign_headers(
request.method,

View file

@ -1,89 +1,4 @@
import re
from blib import Enum, IntEnum, StrEnum
CAPITAL = re.compile("[A-Z][^A-Z]")
class HttpStatus(IntEnum):
# 1xx
Continue = 100
SwitchingProtocols = 101
Processing = 102
# 2xx
Ok = 200
Created = 201
Accepted = 202
NonauthritativeInformation = 203
NoContent = 204
ResetContent = 205
PartialContent = 206
MultiStatus = 207
AlreadyReported = 208
ImUsed = 226
# 3xx
MultipleChoices = 300
MovedPermanently = 301
Found = 302
SeeOther = 303
NotModified = 304
UseProxy = 305
TemporaryRedirect = 307
PermanentRedirect = 308
# 4xx
BadRequest = 400
Unauthorized = 401
PaymentRequired = 402
Forbidden = 403
NotFound = 404
MethodNotAllowed = 405
NotAcceptable = 406
ProxyAuthenticationRequired = 407
RequestTimeout = 408
Conflict = 409
Gone = 410
LengthRequired = 411
PreconditionFailed = 412
PayloadTooLarge = 413
RequestUriTooLarge = 414
UnsupportedMediaType = 415
RequestRangeNotSatisfiable = 416
ExpectationFailed = 417
ImATeapot = 418
EnhanceYourCalm = 420
MisdirectedRequest = 421
UnprocessableEntity = 422
Locked = 423
FailedDependency = 424
UpgradeRequired = 426
PreconditionRequired = 428
TooManyRequests = 429
RequestHeaderFieldsTooLarge = 431
ConnectionClosedWithoutResponse = 444
UnavailableForLegalReasons = 451
ClientClosedRequest = 499
InternalServerError = 500
NotImplemented = 501
BadGateway = 502
ServiceUnavailable = 503
GatewayTimeout = 504
HttpVersionNotSupported = 505
VariantAlsoNegotiates = 506
InsufficientStorage = 507
LoopDetected = 508
NotExtended = 512
NetworkAuthenticationRequired = 511
NetworkConnectTimeoutError = 599
@property
def reason(self) -> str:
return " ".join(CAPITAL.findall(self.name))
from blib import Enum, StrEnum
class PridePalette(tuple[str, ...], Enum):

View file

@ -1,89 +0,0 @@
from typing import Any, Self
from .enums import HttpStatus
class HttpError(Exception):
def __init__(self,
status: HttpStatus | int,
message: str,
headers: dict[str, str] | None = None) -> None:
Exception.__init__(self, f'HTTP ERROR {status}: {message[:100]}')
self.status: HttpStatus = HttpStatus.parse(status)
self.message: str = message
self.headers: dict[str, str] = headers or {}
@classmethod
def MOVED_PERMANENTLY(cls: type[Self], location: str, **kwargs: Any) -> Self:
err = cls(301, f'Resource moved to <a href="{location}">{location}</a>', **kwargs)
err.headers["Location"] = location
return err
@classmethod
def FOUND(cls: type[Self], location: str, **kwargs: Any) -> Self:
err = cls(302, f'Resource moved to <a href="{location}">{location}</a>', **kwargs)
err.headers["Location"] = location
return err
@classmethod
def TEMPORARY_REDIRECT(cls: type[Self], location: str, **kwargs: Any) -> Self:
err = cls(307, f'Resource moved to <a href="{location}">{location}</a>', **kwargs)
err.headers["Location"] = location
return err
@classmethod
def PERMANENT_REDIRECT(cls: type[Self], location: str, **kwargs: Any) -> Self:
err = cls(308, f'Resource moved to <a href="{location}">{location}</a>', **kwargs)
err.headers["Location"] = location
return err
@classmethod
def BAD_REQUEST(cls: type[Self], message: str, **kwargs: Any) -> Self:
return cls(400, message, **kwargs)
@classmethod
def UNAUTHORIZED(cls: type[Self], message: str, **kwargs: Any) -> Self:
return cls(401, message, **kwargs)
@classmethod
def FORBIDDEN(cls: type[Self], message: str, **kwargs: Any) -> Self:
return cls(403, message, **kwargs)
@classmethod
def NOT_FOUND(cls: type[Self], message: str, **kwargs: Any) -> Self:
return cls(404, message, **kwargs)
@classmethod
def METHOD_NOT_ALLOWED(cls: type[Self], message: str, **kwargs: Any) -> Self:
return cls(405, message, **kwargs)
@classmethod
def GONE(cls: type[Self], message: str, **kwargs: Any) -> Self:
return cls(410, message, **kwargs)
@classmethod
def SERVER_ERROR(cls: type[Self], message: str, **kwargs: Any) -> Self:
return cls(500, message, **kwargs)
@classmethod
def NOT_IMPLEMENTED(cls: type[Self], message: str, **kwargs: Any) -> Self:
return cls(501, message, **kwargs)
@property
def reason(self) -> str:
return self.status.reason

View file

@ -10,6 +10,7 @@ from collections.abc import (
Iterator,
ItemsView,
KeysView,
Mapping,
MutableMapping,
Sequence,
ValuesView
@ -268,7 +269,7 @@ class Cookie:
domain: str | None = None,
secure: bool = False,
http_only: bool = False,
same_site: Literal["lax", "strict", "none"] | None = "lax") -> None:
same_site: Literal["lax", "strict", "none"] | None = None) -> None:
"""
Create a new cookie
@ -310,7 +311,7 @@ class Cookie:
self.http_only: bool = False
"Prevent on-page javascript from accessing the cookie"
self.same_site: Literal["lax", "strict", "none"] = same_site or "none"
self.same_site: Literal["lax", "strict", "none"] | None = same_site
"Make sure the cookie stays on the same website"
@ -323,10 +324,10 @@ class Cookie:
:param data: Dictionary of cookie properties
"""
same_site = data.get("same_site", data.get("samesite", "none")).lower()
same_site = data.get("same_site", data.get("samesite", None))
if same_site not in {"lax", "strict", "none"}:
raise ValueError(f"same_site must be 'lax', 'strict', or 'none', not {same_site}")
if same_site not in {"lax", "strict", "none", None}:
raise ValueError(f"same_site must be 'lax', 'strict', 'none' or None, not {same_site}")
max_age = data.get("max_age", data.get("maxage"))
@ -339,7 +340,7 @@ class Cookie:
data.get("domain"),
data.get("secure", False),
data.get("http_only", data.get("httponly", False)),
same_site
same_site.lower() if isinstance(same_site, str) else None # type: ignore[arg-type]
)
@ -397,10 +398,12 @@ class Cookie:
values = [
f"{self.key}={self.value or ''}",
f"path={self.path}",
f"samesite={self.same_site}"
f"path={self.path}"
]
if self.same_site is not None:
values.append(f"samesite={self.same_site}")
if self.max_age is not None:
values.append(f"maxage={self.max_age.seconds}")
@ -576,7 +579,10 @@ class Stream:
return data.get("body", b""), data.get("more_body", False)
async def write_headers(self, status: int, headers: Sequence[tuple[bytes, bytes]]) -> None:
async def write_headers(self,
status: int,
headers: Mapping[str, str],
cookies: Sequence[Cookie] | None = None) -> None:
"""
Write the response headers. If this was already called, nothing happens.
@ -587,16 +593,29 @@ class Stream:
if self._sent_headers:
return
raw_headers: list[tuple[bytes, bytes]] = []
for key, value in headers.items():
if not isinstance(value, str):
# this will be a `logging.warning` call in the future
print(f"not string: {key} {type(value)} {value}") # type: ignore[unreachable]
continue
raw_headers.append((key.encode("utf-8"), value.encode("utf-8")))
for cookie in (cookies or []):
raw_headers.append((b"Set-Cookie", cookie.to_string().encode("utf-8")))
await self.writer({
"type": "http.response.start",
"status": status,
"headers": headers
"headers": raw_headers
})
self._sent_headers = True
async def write_body(self, data: bytes, eof: bool = False) -> None:
async def write_body(self, data: bytes, eof: bool = True) -> None:
"""
Send body data to the client. If an eof was already sent, this does nothing.
@ -613,8 +632,7 @@ class Stream:
await self.writer({
"type": "http.response.body",
"body": data,
"more_body": eof
"more_body": not eof
})
if eof:
self._sent_body = True
self._sent_body = eof

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import os
from aputils import HttpDate, JsonBase
from blib import convert_to_bytes
from blib import HttpError, HttpStatus, convert_to_bytes
from collections.abc import Sequence
from datetime import datetime, timedelta
from mimetypes import guess_type
@ -13,8 +13,6 @@ from pathlib import Path
from typing import Any, Literal, Self
from urllib.parse import quote
from .enums import HttpStatus
from .error import HttpError
from .misc import Cookie, Stream
from .request import Request
@ -37,7 +35,7 @@ class Response:
:param headers: Header items to include in the message
"""
self.cookies: dict[str, Cookie] = {}
self.cookies: list[Cookie] = []
"New cookies to be sent to the client"
self.status: HttpStatus = HttpStatus.parse(status)
@ -158,6 +156,14 @@ class Response:
self.headers.update({"Content-Type": value})
def get_cookie(self, key: str) -> Cookie:
for cookie in self.cookies:
if cookie.key == key:
return cookie
raise KeyError(key)
def set_cookie(self,
key: str,
value: str | None,
@ -170,15 +176,19 @@ class Response:
same_site: Literal["lax", "strict", "none"] | None = "lax") -> Cookie:
cookie = Cookie(key, value, max_age, expires, path, domain, secure, http_only, same_site)
self.cookies[cookie.key] = cookie
self.cookies.append(cookie)
return cookie
def delete_cookie(self, cookie: Cookie) -> Cookie:
deleted_cookie = cookie.copy()
deleted_cookie.set_deleted()
self.cookies[deleted_cookie.key] = deleted_cookie
return deleted_cookie
def delete_cookie(self, key: str) -> Cookie:
try:
cookie = self.get_cookie(key)
except KeyError:
cookie = self.set_cookie(key, "")
cookie.set_deleted()
return cookie
async def send(self, stream: Stream, request: Request) -> None:
@ -189,21 +199,8 @@ class Response:
:param request: Request associated with the response
"""
headers = []
for key, value in self.headers.items():
if not isinstance(value, str):
# this will be a `logging.warning` call in the future
print(f"not string: {key} {type(value)} {value}") # type: ignore[unreachable]
continue
headers.append((key.encode("utf-8"), value.encode("utf-8")))
for cookie in self.cookies.values():
headers.append((b"Set-Cookie", cookie.to_string().encode("utf-8")))
await stream.write_headers(self.status, headers)
await stream.write_body(self.body)
await stream.write_headers(self.status, self.headers, self.cookies)
await stream.write_body(self.body, True)
class FileResponse(Response):
@ -212,7 +209,7 @@ class FileResponse(Response):
def __init__(self,
path: Path | str,
mimetype: str | None = None,
chunk_size: int = 8192,
chunk_size: int = 0,
status: HttpStatus | int = HttpStatus.Ok,
headers: dict[str, str] | None = None) -> None:
"""
@ -237,7 +234,7 @@ class FileResponse(Response):
if isinstance(path, str):
path = Path(path)
self.chunk_size: int = chunk_size if chunk_size > 0 else 8192
self.chunk_size: int = chunk_size if chunk_size > 0 else 131_072
self.path: Path = path.expanduser().resolve()
if not self.path.exists():
@ -246,18 +243,14 @@ class FileResponse(Response):
if not self.path.is_file():
raise ValueError(f"Path is a directory: {self.path}")
self.length = self.path.stat().st_size
async def send(self, stream: Stream, request: Request) -> None:
await stream.write_headers(200, tuple([]))
await stream.write_headers(200, self.headers, self.cookies)
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)
await stream.write_body(fd.read(), True)
class TemplateResponse(Response):
@ -312,7 +305,9 @@ class TemplateResponse(Response):
"request": request,
**self.context
}
text = request.app.template.render(self.name, context, self.pprint)
self.body = text.encode("utf-8")
self.length = len(self.body)
await Response.send(self, stream, request)

View file

@ -1,10 +1,10 @@
import http_router
from blib import HttpError
from collections.abc import Awaitable, Callable, Iterable
from http_router.routes import RouteMatch
from typing import Any, Pattern
from .error import HttpError
from .request import Request
from .response import Response

View file

@ -1,12 +1,6 @@
Enums
=====
.. autoclass:: basgi.HttpStatus
:members:
:show-inheritance:
:undoc-members:
:exclude-members: __new__
.. autoclass:: basgi.PridePalette
:members:
:show-inheritance:

View file

@ -1,6 +0,0 @@
Exceptions
==========
.. autoclass:: basgi.HttpError
:members:
:show-inheritance:

View file

@ -41,12 +41,6 @@ Templates
Enums
-----
:class:`basgi.HttpStatus`
:class:`basgi.PridePalette`
:class:`basgi.SassOutputStyle`
Exceptions
----------
:class:`basgi.HttpError`