Source code for edri.api.headerkit.response
from __future__ import annotations
from collections.abc import Iterable
from datetime import datetime, timezone
from email.utils import format_datetime
from http.cookies import SimpleCookie
from .core import RawHeader
from .errors import InvalidHeader
from .grammar import quote, serialize_params, validate_name_bytes, validate_value_bytes
__all__ = ["ResponseHeaders"]
def _name(name: bytes | str) -> bytes:
if isinstance(name, str):
name = name.encode("ascii")
return validate_name_bytes(name)
def _value(value: bytes | str | int) -> bytes:
if isinstance(value, bytes):
raw = value
elif isinstance(value, int):
raw = str(value).encode("ascii")
else:
raw = value.encode("latin-1")
validate_value_bytes(raw)
return raw
[docs]
class ResponseHeaders:
"""Builder for ASGI response header byte pairs.
It deliberately treats Set-Cookie as append-only and never coalesces it.
"""
def __init__(self, raw: Iterable[RawHeader] = ()) -> None:
self._raw: list[RawHeader] = []
for name, value in raw:
self.append(name, value)
[docs]
def append(self, name: bytes | str, value: bytes | str | int) -> None:
self._raw.append((_name(name), _value(value)))
[docs]
def set(self, name: bytes | str, value: bytes | str | int) -> None:
lname = _name(name)
if lname == b"set-cookie":
raise InvalidHeader("use set_cookie()/append() for Set-Cookie; it is not a singleton")
self.remove(lname)
self.append(lname, value)
[docs]
def remove(self, name: bytes | str) -> None:
lname = _name(name)
self._raw = [(n, v) for n, v in self._raw if n != lname]
[docs]
def extend(self, raw: Iterable[RawHeader]) -> None:
for name, value in raw:
self.append(name, value)
[docs]
def set_content_type(self, media_type: str, **params: str) -> None:
suffix = serialize_params(params)
value = media_type if not suffix else f"{media_type}; {suffix}"
self.set("content-type", value)
[docs]
def set_date(self, name: str, value: datetime | None = None) -> None:
if value is None:
value = datetime.now(timezone.utc)
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
self.set(name, format_datetime(value.astimezone(timezone.utc), usegmt=True))
[docs]
def set_cookie(
self,
name: str,
value: str,
*,
path: str | None = None,
domain: str | None = None,
max_age: int | None = None,
expires: datetime | str | None = None,
secure: bool = False,
httponly: bool = False,
samesite: str | None = None,
) -> None:
cookie = SimpleCookie()
cookie[name] = value
morsel = cookie[name]
if path is not None:
morsel["path"] = path
if domain is not None:
morsel["domain"] = domain
if max_age is not None:
morsel["max-age"] = str(max_age)
if expires is not None:
if isinstance(expires, datetime):
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
expires = format_datetime(expires.astimezone(timezone.utc), usegmt=True)
morsel["expires"] = expires
if secure:
morsel["secure"] = True
if httponly:
morsel["httponly"] = True
if samesite is not None:
s = samesite.capitalize()
if s not in {"Lax", "Strict", "None"}:
raise InvalidHeader("SameSite must be Lax, Strict, or None")
morsel["samesite"] = s
self.append("set-cookie", morsel.OutputString())
[docs]
def delete_cookie(self, name: str, *, path: str = "/", domain: str | None = None) -> None:
self.set_cookie(name, "", path=path, domain=domain, max_age=0, expires="Thu, 01 Jan 1970 00:00:00 GMT")
def __iter__(self):
return iter(self._raw)
def __contains__(self, name: bytes | str) -> bool:
lname = _name(name).lower()
return any(n.lower() == lname for n, _ in self._raw)