chore: rename project coven → hack-house ⛧

Rebrand the Rust client crate (coven/ → hh/, package+binary "hack-house"),
README, CLI strings, and branch (coven → hack-house). Gitea repo renamed
cmd-chat → hack-house to match. Crypto/server logic unchanged; selftest +
golden-vector test still green, binary is now `hack-house`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-05-30 13:29:14 -07:00
parent 82a04f3e12
commit bb1d662ee1
2730 changed files with 712933 additions and 46 deletions
@@ -0,0 +1,82 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Union
from sanic.app import Sanic
from sanic.exceptions import SanicException
from sanic_ext.config import Config
from sanic_ext.exceptions import InitError
class NoDuplicateDict(dict): # type: ignore
def __setitem__(self, key: Any, value: Any) -> None:
if key in self:
raise KeyError(f"Duplicate key: {key}")
return super().__setitem__(key, value)
class Extension(ABC):
_name_registry: dict[str, type[Extension]] = NoDuplicateDict()
_started: bool
name: str
app: Sanic
config: Config
def __init_subclass__(cls):
if not getattr(cls, "name", None) or not cls.name.isalpha():
raise InitError(
"Extensions must be named, and may only contain "
"alphabetic characters"
)
if cls.name in cls._name_registry:
raise InitError(f'Extension "{cls.name}" already exists')
cls._name_registry[cls.name] = cls
def _startup(self, bootstrap):
if self._started:
raise SanicException(
"Extension already started. Cannot start "
f"Extension:{self.name} multiple times."
)
self.startup(bootstrap)
self._started = True
@abstractmethod
def startup(self, bootstrap) -> None: ...
def label(self):
return ""
def render_label(self):
if not self.included:
return "~~disabled~~"
label = self.label()
if not label:
return ""
return f"[{label}]"
def included(self):
return True
@classmethod
def create(
cls,
extension: Union[type[Extension], Extension],
app: Sanic,
config: Config,
) -> Extension:
instance = (
extension if isinstance(extension, Extension) else extension()
)
instance.app = app
instance.config = config
instance._started = False
return instance
@classmethod
def reset(cls) -> None:
cls._name_registry.clear()
@@ -0,0 +1,13 @@
from sanic import Blueprint, Request, Sanic
from sanic.response import json
from sanic.worker.inspector import Inspector
def setup_health_endpoint(app: Sanic) -> None:
bp = Blueprint("SanicHealth", url_prefix=app.config.HEALTH_URL_PREFIX)
@bp.get(app.config.HEALTH_URI_TO_INFO)
async def info(request: Request):
return json(Inspector._make_safe(dict(request.app.m.workers)))
app.blueprint(bp)
@@ -0,0 +1,30 @@
from sanic.exceptions import SanicException
from sanic_ext.extensions.health.endpoint import setup_health_endpoint
from ..base import Extension
from .monitor import HealthMonitor
class HealthExtension(Extension):
name = "health"
MIN_VERSION = (22, 9)
def startup(self, bootstrap) -> None:
if self.config.HEALTH:
if self.config.HEALTH_MONITOR:
if self.MIN_VERSION > bootstrap.sanic_version:
min_version = ".".join(map(str, self.MIN_VERSION))
sanic_version = ".".join(map(str, bootstrap.sanic_version))
raise SanicException(
f"Health monitoring only works with Sanic "
f"v{min_version} and above. It looks like you are "
f"running {sanic_version}."
)
HealthMonitor.setup(self.app)
if self.config.HEALTH_ENDPOINT:
setup_health_endpoint(self.app)
def included(self):
return self.config.HEALTH
@@ -0,0 +1,162 @@
from __future__ import annotations
from asyncio import sleep
from dataclasses import dataclass
from datetime import datetime, timedelta
from multiprocessing import Manager
from queue import Empty, Full
from signal import SIGINT, SIGTERM
from signal import signal as signal_func
from typing import TYPE_CHECKING, Optional
from sanic.application.constants import ServerStage
from sanic.log import logger
if TYPE_CHECKING:
from sanic import Sanic
class Stale(ValueError): ...
@dataclass
class HealthState:
name: str
last: Optional[datetime] = None
misses: int = 0
def report(self, timestamp: int) -> None:
logger.debug(f"Reporting {self.name}")
if self.misses:
logger.info(f"Recovered {self.name}")
self.last = datetime.fromtimestamp(timestamp)
self.misses = 0
def missed(self) -> None:
self.misses += 1
logger.info(
f"Missed health check for {self.name} "
f"({self.misses}/{HealthMonitor.MAX_MISSES})"
)
if self.misses >= HealthMonitor.MAX_MISSES:
raise Stale
def check(self) -> None:
if not self.last:
return
threshhold = timedelta(
seconds=(HealthMonitor.MISSED_THRESHHOLD * (self.misses + 1))
)
if self.last < (datetime.now() - threshhold):
self.missed()
def reset(self) -> None:
self.misses = 0
self.last = datetime.now()
def send_healthy(name, queue):
health = (name, datetime.now().timestamp())
logger.debug(f"Sending health: {health}", extra={"verbosity": 1})
try:
queue.put_nowait(health)
except Full:
...
async def health_check(app: Sanic):
sent = datetime.now()
while app.state.stage is ServerStage.SERVING:
now = datetime.now()
if sent < now - timedelta(seconds=HealthMonitor.REPORT_INTERVAL):
send_healthy(app.m.name, app.shared_ctx.health_queue)
sent = now
await sleep(0.1)
async def start_health_check(app: Sanic):
app.add_task(health_check(app), name="health_check")
async def prepare_health_monitor(app, *_):
HealthMonitor.prepare(app)
async def setup_health_monitor(app, *_):
health = HealthMonitor(app)
process_names = [
process.name for process in app.manager.transient_processes
]
app.manager.manage(
"HealthMonitor",
health,
{
"process_names": process_names,
"health_queue": app.shared_ctx.health_queue,
},
)
class HealthMonitor:
MAX_MISSES = 3
REPORT_INTERVAL = 5
MISSED_THRESHHOLD = 10
def __init__(self, app: Sanic):
self.run = True
self.monitor_publisher = app.manager.monitor_publisher
def __call__(self, process_names, health_queue) -> None:
signal_func(SIGINT, self.stop)
signal_func(SIGTERM, self.stop)
now = datetime.now()
health_state = {
process_name: HealthState(last=now, name=process_name)
for process_name in process_names
}
while self.run:
try:
name, timestamp = health_queue.get(timeout=0.05)
except Empty:
...
else:
health_state[name].report(timestamp)
for state in health_state.values():
try:
state.check()
except Stale:
state.reset()
self.monitor_publisher.send(state.name)
def stop(self, *_):
self.run = False
@classmethod
def prepare(cls, app: Sanic):
sync_manager = Manager()
health_queue = sync_manager.Queue(maxsize=app.state.workers * 2)
app.shared_ctx.health_queue = health_queue
@classmethod
def setup(
cls,
app: Sanic,
max_misses: Optional[int] = None,
report_interval: Optional[int] = None,
missed_threshhold: Optional[int] = None,
):
HealthMonitor.MAX_MISSES = max_misses or app.config.HEALTH_MAX_MISSES
HealthMonitor.REPORT_INTERVAL = (
report_interval or app.config.HEALTH_REPORT_INTERVAL
)
HealthMonitor.MISSED_THRESHHOLD = (
missed_threshhold or app.config.HEALTH_MISSED_THRESHHOLD
)
app.main_process_start(prepare_health_monitor)
app.main_process_ready(setup_health_monitor)
app.after_server_start(start_health_check)
@@ -0,0 +1,396 @@
import re
from dataclasses import dataclass
from datetime import timedelta
from types import SimpleNamespace
from typing import Any, Optional, Union
from sanic import HTTPResponse, Request, Sanic
from sanic.exceptions import SanicException
from sanic.helpers import Default, _default
from sanic.log import logger
from sanic_ext.config import PRIORITY
WILDCARD_PATTERN = re.compile(r".*")
ORIGIN_HEADER = "access-control-allow-origin"
ALLOW_HEADERS_HEADER = "access-control-allow-headers"
ALLOW_METHODS_HEADER = "access-control-allow-methods"
EXPOSE_HEADER = "access-control-expose-headers"
CREDENTIALS_HEADER = "access-control-allow-credentials"
REQUEST_METHOD_HEADER = "access-control-request-method"
REQUEST_HEADERS_HEADER = "access-control-request-headers"
MAX_AGE_HEADER = "access-control-max-age"
VARY_HEADER = "vary"
@dataclass(frozen=True)
class CORSSettings:
allow_headers: frozenset[str]
allow_methods: frozenset[str]
allow_origins: tuple[re.Pattern, ...]
always_send: bool
automatic_options: bool
expose_headers: frozenset[str]
max_age: str
send_wildcard: bool
supports_credentials: bool
def add_cors(app: Sanic) -> None:
_setup_cors_settings(app)
@app.on_response
async def _add_cors_headers(request, response):
preflight = (
request.app.ctx.cors.automatic_options
and request.method == "OPTIONS"
)
if preflight and not request.headers.get(REQUEST_METHOD_HEADER):
logger.info(
"No Access-Control-Request-Method header found on request. "
"CORS headers will not be applied."
)
return
_add_origin_header(request, response)
if ORIGIN_HEADER not in response.headers:
return
_add_expose_header(request, response)
_add_credentials_header(request, response)
_add_vary_header(request, response)
if preflight:
_add_max_age_header(request, response)
_add_allow_header(request, response)
_add_methods_header(request, response)
@app.before_server_start(priority=PRIORITY)
async def _assign_cors_settings(app, _):
for group in app.router.groups.values():
_cors = SimpleNamespace()
for route in group:
cors = getattr(route.handler, "__cors__", None)
if cors:
for key, value in cors.__dict__.items():
setattr(_cors, key, value)
for route in group:
route.ctx._cors = _cors
def cors(
*,
origin: Union[str, Default] = _default,
expose_headers: Union[list[str], Default] = _default,
allow_headers: Union[list[str], Default] = _default,
allow_methods: Union[list[str], Default] = _default,
supports_credentials: Union[bool, Default] = _default,
max_age: Union[str, int, timedelta, Default] = _default,
):
def decorator(f):
f.__cors__ = SimpleNamespace(
_cors_origin=origin,
_cors_expose_headers=expose_headers,
_cors_supports_credentials=supports_credentials,
_cors_allow_origins=(
_parse_allow_origins(origin)
if origin is not _default
else origin
),
_cors_allow_headers=(
_parse_allow_headers(allow_headers)
if allow_headers is not _default
else allow_headers
),
_cors_allow_methods=(
_parse_allow_methods(allow_methods)
if allow_methods is not _default
else allow_methods
),
_cors_max_age=(
_parse_max_age(max_age) if max_age is not _default else max_age
),
)
return f
return decorator
def _setup_cors_settings(app: Sanic) -> None:
if app.config.CORS_ORIGINS == "*" and app.config.CORS_SUPPORTS_CREDENTIALS:
raise SanicException(
"Cannot use supports_credentials in conjunction with "
"an origin string of '*'. See: "
"http://www.w3.org/TR/cors/#resource-requests"
)
allow_headers = _get_allow_headers(app)
allow_methods = _get_allow_methods(app)
allow_origins = _get_allow_origins(app)
expose_headers = _get_expose_headers(app)
max_age = _get_max_age(app)
app.ctx.cors = CORSSettings(
allow_headers=allow_headers,
allow_methods=allow_methods,
allow_origins=allow_origins,
always_send=app.config.CORS_ALWAYS_SEND,
automatic_options=app.config.CORS_AUTOMATIC_OPTIONS,
expose_headers=expose_headers,
max_age=max_age,
send_wildcard=(
app.config.CORS_SEND_WILDCARD and WILDCARD_PATTERN in allow_origins
),
supports_credentials=app.config.CORS_SUPPORTS_CREDENTIALS,
)
def _get_from_cors_ctx(request: Request, key: str, default: Any = None):
if request.route:
value = getattr(request.route.ctx._cors, key, default)
if value is not _default:
return value
return default
def _add_origin_header(request: Request, response: HTTPResponse) -> None:
request_origin = request.headers.get("origin")
origin_value = ""
allow_origins = _get_from_cors_ctx(
request,
"_cors_allow_origins",
request.app.ctx.cors.allow_origins,
)
fallback_origin = _get_from_cors_ctx(
request,
"_cors_origin",
request.app.config.CORS_ORIGINS,
)
if request_origin:
if request.app.ctx.cors.send_wildcard:
origin_value = "*"
else:
for pattern in allow_origins:
if pattern.match(request_origin):
origin_value = request_origin
break
elif request.app.ctx.cors.always_send:
if WILDCARD_PATTERN in allow_origins:
origin_value = "*"
else:
if isinstance(fallback_origin, str) and "," not in fallback_origin:
origin_value = fallback_origin
else:
origin_value = request.app.config.get("SERVER_NAME", "")
if origin_value:
response.headers[ORIGIN_HEADER] = origin_value
def _add_expose_header(request: Request, response: HTTPResponse) -> None:
with_credentials = _is_request_with_credentials(request)
headers = None
expose_headers = _get_from_cors_ctx(
request, "_cors_expose_headers", request.app.ctx.cors.expose_headers
)
# MDN: The value "*" only counts as a special wildcard value for requests
# without credentials (requests without HTTP cookies or HTTP
# authentication information). In requests with credentials, it is
# treated as the literal header name "*" without special semantics.
# Note that the Authorization header can't be wildcarded and always
# needs to be listed explicitly.
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
if not with_credentials and "*" in expose_headers:
headers = ["*"]
elif expose_headers:
headers = expose_headers
if headers:
response.headers[EXPOSE_HEADER] = ",".join(headers)
def _add_credentials_header(request: Request, response: HTTPResponse) -> None:
supports_credentials = _get_from_cors_ctx(
request,
"_cors_supports_credentials",
request.app.ctx.cors.supports_credentials,
)
if supports_credentials:
response.headers[CREDENTIALS_HEADER] = "true"
def _add_allow_header(request: Request, response: HTTPResponse) -> None:
with_credentials = _is_request_with_credentials(request)
request_headers = {
h.strip().lower()
for h in request.headers.get(REQUEST_HEADERS_HEADER, "").split(",")
}
allow_headers = _get_from_cors_ctx(
request, "_cors_allow_headers", request.app.ctx.cors.allow_headers
)
# MDN: The value "*" only counts as a special wildcard value for requests
# without credentials (requests without HTTP cookies or HTTP
# authentication information). In requests with credentials,
# it is treated as the literal header name "*" without special semantics.
# Note that the Authorization header can't be wildcarded and always needs
# to be listed explicitly.
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
if not with_credentials and "*" in allow_headers:
allow_headers = ["*"]
else:
allow_headers = request_headers & allow_headers
if allow_headers:
response.headers[ALLOW_HEADERS_HEADER] = ",".join(allow_headers)
def _add_max_age_header(request: Request, response: HTTPResponse) -> None:
max_age = _get_from_cors_ctx(
request, "_cors_max_age", request.app.ctx.cors.max_age
)
if max_age:
response.headers[MAX_AGE_HEADER] = max_age
def _add_methods_header(request: Request, response: HTTPResponse) -> None:
# MDN: The value "*" only counts as a special wildcard value for requests
# without credentials (requests without HTTP cookies or HTTP
# authentication information). In requests with credentials, it
# is treated as the literal method name "*" without
# special semantics.
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
methods = None
with_credentials = _is_request_with_credentials(request)
allow_methods = _get_from_cors_ctx(
request, "_cors_allow_methods", request.app.ctx.cors.allow_methods
)
if not with_credentials and "*" in allow_methods:
methods = {"*"}
elif request.route:
group = request.app.router.groups.get(request.route.segments)
if group:
group_methods = {method.lower() for method in group.methods}
if allow_methods:
methods = group_methods & allow_methods
else:
methods = group_methods
if methods:
response.headers[ALLOW_METHODS_HEADER] = ",".join(methods).upper()
def _add_vary_header(request: Request, response: HTTPResponse) -> None:
allow_origins = _get_from_cors_ctx(
request,
"_cors_allow_origins",
request.app.ctx.cors.allow_origins,
)
if len(allow_origins) > 1:
response.headers[VARY_HEADER] = "origin"
def _get_allow_origins(app: Sanic) -> tuple[re.Pattern, ...]:
origins = app.config.CORS_ORIGINS
return _parse_allow_origins(origins)
def _parse_allow_origins(
value: Union[str, re.Pattern, list[Union[str, re.Pattern]]],
) -> tuple[re.Pattern, ...]:
origins: Optional[
Union[list[str], list[re.Pattern], list[Union[str, re.Pattern]]]
] = None
if value and isinstance(value, str):
if value == "*":
origins = [WILDCARD_PATTERN]
else:
origins = [origin.strip() for origin in value.split(",")]
elif isinstance(value, re.Pattern):
origins = [value]
elif isinstance(value, list):
origins = value
return tuple(
pattern
if isinstance(pattern, re.Pattern)
else re.compile(re.escape(pattern))
for pattern in (origins or [])
)
def _get_expose_headers(app: Sanic) -> frozenset[str]:
expose_headers = (
(
app.config.CORS_EXPOSE_HEADERS
if isinstance(
app.config.CORS_EXPOSE_HEADERS, (list, set, frozenset, tuple)
)
else app.config.CORS_EXPOSE_HEADERS.split(",")
)
if app.config.CORS_EXPOSE_HEADERS
else tuple()
)
return frozenset(header.lower() for header in expose_headers)
def _get_allow_headers(app: Sanic) -> frozenset[str]:
return _parse_allow_headers(app.config.CORS_ALLOW_HEADERS)
def _parse_allow_headers(value: str) -> frozenset[str]:
allow_headers = (
(
value
if isinstance(
value,
(list, set, frozenset, tuple),
)
else value.split(",")
)
if value
else tuple()
)
return frozenset(header.lower() for header in allow_headers)
def _get_max_age(app: Sanic) -> str:
return _parse_max_age(app.config.CORS_MAX_AGE or "")
def _parse_max_age(value) -> str:
max_age = value or ""
if isinstance(max_age, timedelta):
max_age = str(int(max_age.total_seconds()))
return str(max_age)
def _get_allow_methods(app: Sanic) -> frozenset[str]:
return _parse_allow_methods(app.config.CORS_METHODS)
def _parse_allow_methods(value) -> frozenset[str]:
allow_methods = (
(
value
if isinstance(
value,
(list, set, frozenset, tuple),
)
else value.split(",")
)
if value
else tuple()
)
return frozenset(method.lower() for method in allow_methods)
def _is_request_with_credentials(request: Request) -> bool:
return bool(request.headers.get("authorization") or request.cookies)
@@ -0,0 +1,34 @@
from ...exceptions import InitError
from ..base import Extension
from .cors import add_cors
from .methods import add_auto_handlers, add_http_methods
class HTTPExtension(Extension):
name = "http"
def startup(self, _) -> None:
self.all_methods: bool = self.config.HTTP_ALL_METHODS
self.auto_head: bool = self.config.HTTP_AUTO_HEAD
self.auto_options: bool = self.config.HTTP_AUTO_OPTIONS
self.auto_trace: bool = self.config.HTTP_AUTO_TRACE
self.cors: bool = self.config.CORS
if self.all_methods:
add_http_methods(self.app, ["CONNECT", "TRACE"])
if self.auto_head or self.auto_options or self.auto_trace:
add_auto_handlers(
self.app, self.auto_head, self.auto_options, self.auto_trace
)
if self.cors:
add_cors(self.app)
else:
return
if self.app.ctx.cors.automatic_options and not self.auto_options:
raise InitError(
"Configuration mismatch. If CORS_AUTOMATIC_OPTIONS is set to "
"True, then you must run SanicExt with auto_options=True"
)
@@ -0,0 +1,146 @@
from collections.abc import Sequence
from functools import partial
from inspect import isawaitable
from operator import itemgetter
from typing import Union
from sanic import Sanic
from sanic.constants import HTTPMethod
from sanic.exceptions import SanicException
from sanic.response import empty, raw
from sanic_ext.config import PRIORITY
from sanic_ext.extensions.openapi import openapi
from sanic_ext.utils.route import clean_route_name
def add_http_methods(
app: Sanic, methods: Sequence[Union[str, HTTPMethod]]
) -> None:
"""
Adds HTTP methods to an app
:param app: Your Sanic app
:type app: Sanic
:param methods: The http methods being added, eg: CONNECT, TRACE
:type methods: Sequence[str]
"""
app.router.ALLOWED_METHODS = tuple(
[*app.router.ALLOWED_METHODS, *methods] # type: ignore
)
def add_auto_handlers(
app: Sanic, auto_head: bool, auto_options: bool, auto_trace: bool
) -> None:
if auto_trace and "TRACE" not in app.router.ALLOWED_METHODS:
raise SanicException(
"Cannot use apply(..., auto_trace=True) if TRACE is not an "
"allowed HTTP method. Make sure apply(..., all_http_methods=True) "
"has been set."
)
async def head_handler(request, get_handler, *args, **kwargs):
retval = get_handler(request, *args, **kwargs)
if isawaitable(retval):
retval = await retval
return retval
async def options_handler(request, methods, *args, **kwargs):
resp = empty()
resp.headers["allow"] = ",".join([*methods, "OPTIONS"])
return resp
async def trace_handler(request):
cleaned_head = b""
for line in request.head.split(b"\r\n"):
first_word, _ = line.split(b" ", 1)
if (
first_word.lower().replace(b":", b"").decode("utf-8")
not in request.app.config.TRACE_EXCLUDED_HEADERS
):
cleaned_head += line + b"\r\n"
message = "\r\n\r\n".join(
[part.decode("utf-8") for part in [cleaned_head, request.body]]
)
return raw(message, content_type="message/http")
@app.before_server_start(priority=PRIORITY)
def _add_handlers(app, _):
nonlocal auto_head
nonlocal auto_options
if auto_head:
app.router.reset()
for group in app.router.groups.values():
if "GET" in group.methods and "HEAD" not in group.methods:
for route in group:
if "GET" in route.methods:
host = route.requirements.get("host")
name = f"{route.name}_head"
handler = openapi.definition(
summary=clean_route_name(route.name).title(),
description="Retrieve HEAD details",
)(partial(head_handler, get_handler=route.handler))
handler.__auto_handler__ = True
handler.__route_handler__ = route.handler
app.add_route(
handler=handler,
uri=group.uri,
methods=["HEAD"],
strict_slashes=group.strict,
name=name,
host=host,
unquote=group.unquote,
)
app.finalize()
if auto_trace:
app.router.reset()
for group in app.router.groups.values():
if "TRACE" not in group.methods:
app.add_route(
handler=trace_handler,
uri=group.uri,
methods=["TRACE"],
strict_slashes=group.strict,
)
app.finalize()
if auto_options:
app.router.reset()
for group in app.router.groups.values():
if "OPTIONS" not in group.methods:
if not group.requirements:
hosts = [None]
else:
hosts = set(
map(itemgetter("host"), group.requirements)
)
try:
base_route = next(
r for r in group if not r.name.endswith("_head")
)
except StopIteration:
base_route = group[0]
name = f"{base_route.name}_options"
handler = openapi.definition(
summary=clean_route_name(base_route.name).title(),
description="Retrieve OPTIONS details",
)(partial(options_handler, methods=group.methods))
handler.__auto_handler__ = True
app.add_route(
handler=handler,
uri=group.uri,
methods=["OPTIONS"],
strict_slashes=group.strict,
name=name,
host=hosts,
unquote=group.unquote,
)
app.finalize()
@@ -0,0 +1,180 @@
from __future__ import annotations
from dataclasses import is_dataclass
from inspect import isclass, iscoroutine
from typing import (
TYPE_CHECKING,
Any,
Callable,
Optional,
get_args,
get_type_hints,
)
from sanic import Request
from sanic.app import Sanic
from sanic.exceptions import ServerError
from sanic_ext.exceptions import InitError
from sanic_ext.utils.typing import (
is_attrs,
is_msgspec,
is_optional,
is_pydantic,
)
if TYPE_CHECKING:
from .registry import ConstantRegistry, InjectionRegistry
class Constructor:
EXEMPT_ANNOTATIONS = (Request,)
def __init__(
self, func: Callable[..., Any], request_arg: Optional[str] = None
):
self.func = func
self.injections: dict[str, tuple[type, Constructor]] = {}
self.constants: dict[str, Any] = {}
self.pass_kwargs: bool = False
self.request_arg = request_arg
def __str__(self) -> str:
return f"<{self.__class__.__name__}:{self.func.__name__}>"
def __repr__(self) -> str:
return f"<{self.__class__.__name__}(func={self.func.__name__})>"
async def __call__(self, request, **kwargs):
try:
args = await gather_args(self.injections, request, **kwargs)
args.update(self.constants)
if self.pass_kwargs:
args.update(kwargs)
if self.request_arg:
args[self.request_arg] = request
retval = self.func(**args)
if iscoroutine(retval):
retval = await retval
return retval
except TypeError as e:
raise ServerError(
"Failure to inject dependencies. Make sure that all "
f"dependencies for '{self.func.__name__}' have been "
"registered."
) from e
def prepare(
self,
app: Sanic,
injection_registry: InjectionRegistry,
constant_registry: ConstantRegistry,
allowed_types: set[type[object]],
) -> None:
hints = self._get_hints()
hints.pop("return", None)
missing = []
for param, annotation in hints.items():
if param in constant_registry:
self.constants[param] = getattr(app.config, param.upper())
if annotation in allowed_types:
self.pass_kwargs = True
if is_optional(annotation):
annotation = get_args(annotation)[0]
if not isclass(annotation):
missing.append((param, annotation))
continue
if issubclass(annotation, Request):
self.request_arg = param
if (
annotation not in self.EXEMPT_ANNOTATIONS
and not issubclass(annotation, self.EXEMPT_ANNOTATIONS)
and annotation not in allowed_types
):
dependency = injection_registry.get(annotation)
if not dependency:
missing.append((param, annotation))
continue
self.injections[param] = (annotation, dependency)
if missing:
dependencies = "\n".join(
[f" - {param}: {annotation}" for param, annotation in missing]
)
raise InitError(
"Unable to resolve dependencies for "
f"'{self.func.__name__}'. Could not find the following "
f"dependencies:\n{dependencies}.\nMake sure the dependencies "
"are declared using ext.injection. See "
"https://sanicframework.org/en/plugins/sanic-ext/injection."
"html#injecting-services for more details."
)
checked: set[type[object]] = set()
current: set[type[object]] = set()
self.check_circular(checked, current)
def check_circular(
self,
checked: set[type[object]],
current: set[type[object]],
) -> None:
dependencies = set(self.injections.values())
for dependency, constructor in dependencies:
self._visit(dependency, constructor, checked, current)
def _visit(
self,
dependency: type[object],
constructor: Constructor,
checked: set[type[object]],
current: set[type[object]],
):
if dependency in checked:
return
elif dependency in current:
raise InitError(
"Circular dependency injection detected on "
f"'{self.func.__name__}'. Check dependencies of "
f"'{constructor.func.__name__}' which may contain "
f"circular dependency chain with {dependency}."
)
current.add(dependency)
constructor.check_circular(checked, current)
current.remove(dependency)
checked.add(dependency)
def _get_hints(self):
if (
not isclass(self.func)
or is_dataclass(self.func)
or is_attrs(self.func)
or is_pydantic(self.func)
or is_msgspec(self.func)
):
return get_type_hints(self.func)
elif isclass(self.func):
return get_type_hints(self.func.__init__)
raise InitError(f"Cannot get type hints for {self.func}")
async def gather_args(injections, request, **kwargs) -> dict[str, Any]:
return {
name: await do_cast(_type, constructor, request, **kwargs)
for name, (_type, constructor) in injections.items()
}
async def do_cast(_type, constructor, request, **kwargs):
cast = constructor if constructor else _type
args = [request] if constructor else []
retval = cast(*args, **kwargs)
if iscoroutine(retval):
retval = await retval
return retval
@@ -0,0 +1,22 @@
from ..base import Extension
from .injector import add_injection
from .registry import ConstantRegistry, InjectionRegistry
class InjectionExtension(Extension):
name = "injection"
def startup(self, bootstrap) -> None:
self.constant_registry = ConstantRegistry(self.app.config)
self.registry = InjectionRegistry()
add_injection(self.app, self.registry, self.constant_registry)
bootstrap._injection_registry = self.registry
bootstrap._constant_registry = self.constant_registry
if self.config.INJECTION_LOAD_CUSTOM_CONSTANTS:
self.app.ext.load_constants()
def label(self):
return (
f"{self.registry.length} dependencies; "
f"{self.constant_registry.length} constants"
)
@@ -0,0 +1,123 @@
from __future__ import annotations
from functools import partial
from inspect import getmembers, isclass, isfunction
from typing import Any, Callable, Optional, get_type_hints
from sanic import Sanic
from sanic.constants import HTTP_METHODS
from sanic_ext.config import PRIORITY
from sanic_ext.extensions.injection.constructor import gather_args
from .registry import ConstantRegistry, InjectionRegistry, SignatureRegistry
def add_injection(
app: Sanic,
injection_registry: InjectionRegistry,
constant_registry: ConstantRegistry,
) -> None:
signature_registry = _setup_signature_registry(
app, injection_registry, constant_registry
)
@app.listener("before_server_start", priority=PRIORITY)
async def finalize_injections(app: Sanic, _):
router_converters = {
allowed[0] for allowed in app.router.regex_types.values()
}
router_types = set()
for converter in router_converters:
if isclass(converter):
router_types.add(converter)
elif isfunction(converter):
hints = get_type_hints(converter)
if return_type := hints.get("return"):
router_types.add(return_type)
injection_registry.finalize(app, constant_registry, router_types)
injection_signal = app.ext.config.INJECTION_SIGNAL
injection_priority = app.ext.config.INJECTION_PRIORITY
@app.signal(injection_signal, priority=injection_priority)
async def inject_kwargs(request, **_):
nonlocal signature_registry
for name in (
request.route.name,
f"{request.route.name}_{request.method.lower()}",
):
dependencies, constants = signature_registry.get(
name, (None, None)
)
if dependencies or constants:
break
if dependencies:
injected_args = await gather_args(
dependencies, request, **request.match_info
)
request.match_info.update(injected_args)
if constants:
request.match_info.update(constants)
def _http_method_predicate(member):
return isfunction(member) and member.__name__ in HTTP_METHODS
def _setup_signature_registry(
app: Sanic,
injection_registry: InjectionRegistry,
constant_registry: ConstantRegistry,
) -> SignatureRegistry:
registry = SignatureRegistry()
@app.listener("before_server_start", priority=PRIORITY - 1)
async def setup_signatures(app, _):
nonlocal registry
for route in app.router.routes:
if ".openapi." in route.name:
continue
handlers = [(route.name, route.handler)]
viewclass = getattr(route.handler, "view_class", None)
if viewclass:
handlers = [
(f"{route.name}_{name}", member)
for name, member in getmembers(
viewclass, _http_method_predicate
)
]
for name, handler in handlers:
if route_handler := getattr(
handler, "__route_handler__", None
):
handler = route_handler
if isinstance(handler, partial):
if handler.func == app._websocket_handler:
handler = handler.args[0]
else:
handler = handler.func
try:
hints = get_type_hints(handler)
except TypeError:
continue
dependencies: dict[
str, tuple[type, Optional[Callable[..., Any]]]
] = {}
constants: dict[str, Any] = {}
for param, annotation in hints.items():
if annotation in injection_registry:
dependencies[param] = (
annotation,
injection_registry[annotation],
)
if param in constant_registry:
constants[param] = app.config[param.upper()]
registry.register(name, dependencies, constants)
return registry
@@ -0,0 +1,115 @@
from __future__ import annotations
from typing import Any, Callable, Optional
from sanic.app import Sanic
from sanic.config import Config
from .constructor import Constructor
class InjectionRegistry:
def __init__(self):
self._registry: dict[type, Optional[Callable[..., Any]]] = {}
def __getitem__(self, key):
return self._registry[key]
def __str__(self) -> str:
return str(self._registry)
def __contains__(self, other: Any):
return other in self._registry
def get(self, key, default=None):
return self._registry.get(key, default)
def register(
self,
_type: type,
constructor: Optional[Callable[..., Any]],
request_arg: Optional[str] = None,
) -> None:
constructor = constructor or _type
constructor = Constructor(constructor, request_arg=request_arg)
self._registry[_type] = constructor
def finalize(
self, app: Sanic, constant_registry: ConstantRegistry, allowed_types
):
for constructor in self._registry.values():
if isinstance(constructor, Constructor):
constructor.prepare(
app, self, constant_registry, allowed_types
)
@property
def length(self):
return len(self._registry)
class SignatureRegistry:
def __init__(self):
self._registry: dict[
str,
tuple[
dict[str, tuple[type, Optional[Callable[..., Any]]]],
dict[str, Any],
],
] = {}
def __getitem__(self, key):
return self._registry[key]
def __str__(self) -> str:
return str(self._registry)
def get(self, key, default=None):
return self._registry.get(key, default)
def register(
self,
route_name: str,
dependencies: dict[str, tuple[type, Optional[Callable[..., Any]]]],
constants: Optional[dict[str, Any]] = None,
) -> None:
self._registry[route_name] = (dependencies, constants or {})
class ConstantRegistry:
def __init__(self, config: Config):
self._config = config
self._registry: set[str] = set()
def __str__(self) -> str:
return str(self._registry)
def __iter__(self):
return iter(self._registry)
def __getitem__(self, key):
return self._registry[key]
def __contains__(self, other: Any):
return other in self._registry
def register(self, key: str, value: Any, overwrite: bool):
attribute = key.upper()
if attribute in self._config and not overwrite:
raise ValueError(
f"A value for {attribute} has already been assigned"
)
key = key.lower()
setattr(self._config, attribute, value)
return self._registry.add(key)
def get(self, key: str):
key = key.lower()
if key not in self._registry:
raise ValueError
attribute = key.upper()
return getattr(self._config, attribute)
@property
def length(self):
return len(self._registry)
@@ -0,0 +1,24 @@
from sanic.exceptions import SanicException
from ..base import Extension
from .logger import Logger
class LoggingExtension(Extension):
name = "logging"
MIN_VERSION = (22, 9)
def startup(self, bootstrap) -> None:
if self.included():
if self.MIN_VERSION > bootstrap.sanic_version:
min_version = ".".join(map(str, self.MIN_VERSION))
sanic_version = ".".join(map(str, bootstrap.sanic_version))
raise SanicException(
f"The logging extension only works with Sanic "
f"v{min_version} and above. It looks like you are "
f"running {sanic_version}."
)
Logger.setup(self.app)
def included(self):
return self.config.LOGGING
@@ -0,0 +1,116 @@
import logging
from typing import Any, Optional, TypedDict
class LoggerConfig(TypedDict):
level: str
propagate: bool
handlers: list[str]
class HandlerConfig(TypedDict):
class_: str
level: str
stream: Optional[str]
formatter: Optional[str]
class FormatterConfig(TypedDict):
class_: str
format: Optional[str]
datefmt: Optional[str]
class LoggingConfig(TypedDict):
version: int
disable_existing_loggers: bool
formatters: dict[str, FormatterConfig]
handlers: dict[str, HandlerConfig]
loggers: dict[str, LoggerConfig]
class LoggingConfigExtractor:
def __init__(self):
self.version = 1
self.disable_existing_loggers = False
self.formatters: dict[str, FormatterConfig] = {}
self.handlers: dict[str, HandlerConfig] = {}
self.loggers: dict[str, LoggerConfig] = {}
def add_logger(self, logger: logging.Logger):
self._extract_logger_config(logger)
self._extract_handlers(logger)
def compile(self) -> LoggingConfig:
output = {
"version": self.version,
"disable_existing_loggers": self.disable_existing_loggers,
"formatters": self.formatters,
"handlers": self.handlers,
"loggers": self.loggers,
}
return self._clean(output)
def _extract_logger_config(self, logger: logging.Logger):
config: LoggerConfig = {
"level": logging.getLevelName(logger.level),
"propagate": logger.propagate,
"handlers": [handler.get_name() for handler in logger.handlers],
}
self.loggers[logger.name] = config
def _extract_handlers(self, logger: logging.Logger):
for handler in logger.handlers:
self._extract_handler_config(handler)
def _extract_handler_config(self, handler: logging.Handler):
handler_name = handler.get_name()
if handler_name in self.handlers:
return
config: HandlerConfig = {
"class_": self._full_name(handler),
"level": logging.getLevelName(handler.level),
"formatter": (
self._formatter_name(handler.formatter)
if handler.formatter
else None
),
"stream": None,
}
# if (stream := getattr(handler, "stream", None)) and (
# stream_name := getattr(stream, "name", None)
# ):
# config["stream"] = stream_name
self.handlers[handler_name] = config
if handler.formatter:
self._extract_formatter_config(handler.formatter)
def _extract_formatter_config(self, formatter: logging.Formatter):
formatter_name = self._formatter_name(formatter)
if formatter_name in self.formatters:
return
config: FormatterConfig = {
"class_": self._full_name(formatter),
"format": formatter._fmt,
"datefmt": formatter.datefmt,
}
self.formatters[formatter_name] = config
def _clean(self, d: dict[str, Any]) -> dict[str, Any]:
return {
k.replace("class_", "class"): self._clean(v)
if isinstance(v, dict)
else v
for k, v in d.items()
}
@staticmethod
def _formatter_name(
formatter: logging.Formatter, prefix: str = "formatter"
):
return f"{prefix}_{formatter.__class__.__name__}".lower()
@staticmethod
def _full_name(obj):
return f"{obj.__module__}.{obj.__class__.__name__}"
@@ -0,0 +1,126 @@
import logging
from collections import defaultdict
from logging import LogRecord
from logging.handlers import QueueHandler
from multiprocessing import Manager
from queue import Empty, Full
from signal import SIGINT, SIGTERM
from signal import signal as signal_func
from sanic import Sanic
from sanic.log import logger as root_logger
from sanic.log import logger as server_logger
from sanic.logging.setup import setup_logging
from sanic_ext.extensions.logging.extractor import LoggingConfigExtractor
async def prepare_logger(app: Sanic, *_):
Logger.prepare(app)
async def setup_logger(app: Sanic, *_):
logger = Logger()
extractor = LoggingConfigExtractor()
for logger_name in app.config.LOGGERS:
extractor.add_logger(logging.getLogger(logger_name))
app.manager.manage(
"Logger",
logger,
{
"queue": app.shared_ctx.logger_queue,
"config": extractor.compile(),
},
transient=True,
)
class SanicQueueHandler(QueueHandler):
def emit(self, record: LogRecord) -> None:
try:
return super().enqueue(record)
except Full:
server_logger.warning(
"Background logger is full. Emitting log in process."
)
server_logger.handle(record)
async def setup_server_logging(app: Sanic):
qhandler = SanicQueueHandler(app.shared_ctx.logger_queue)
app.ctx._logger_handlers = defaultdict(list)
app.ctx._qhandler = qhandler
for logger_name in app.config.LOGGERS:
logger_instance = logging.getLogger(logger_name)
for handler in logger_instance.handlers:
logger_instance.removeHandler(handler)
logger_instance.addHandler(qhandler)
async def remove_server_logging(app: Sanic):
for logger, handlers in app.ctx._logger_handlers.items():
logger.removeHandler(app.ctx._qhandler)
for handler in handlers:
logger.addHandler(handler)
class Logger:
LOGGERS: list[str] = []
def __init__(self):
self.run = True
self.loggers = {
logger: logging.getLogger(logger) for logger in self.LOGGERS
}
def __call__(self, queue, config) -> None:
signal_func(SIGINT, self.stop)
signal_func(SIGTERM, self.stop)
logging.config.dictConfig(config)
setup_loggers = set(config["loggers"].keys())
enabled_loggers = set(self.loggers.keys())
missing = enabled_loggers - setup_loggers
root_logger.info(
f"Setup background logging for: {', '.join(setup_loggers)}"
)
if missing:
root_logger.warning(
f"Logger config not found for: {', '.join(missing)}"
)
setup_logging(True, no_color=False, log_extra=True)
while self.run:
try:
record: LogRecord = queue.get(timeout=0.05)
except Empty:
continue
logger = self.loggers.get(record.name)
logger.handle(record)
def stop(self, *_):
if self.run:
self.run = False
@classmethod
def update_cls_loggers(cls, logger_names: list[str]):
cls.LOGGERS = logger_names
@classmethod
def prepare(cls, app: Sanic):
sync_manager = Manager()
logger_queue = sync_manager.Queue(
maxsize=app.config.LOGGING_QUEUE_MAX_SIZE
)
app.shared_ctx.logger_queue = logger_queue
cls.update_cls_loggers(app.config.LOGGERS)
@classmethod
def setup(cls, app: Sanic):
app.main_process_start(prepare_logger)
app.main_process_ready(setup_logger)
app.before_server_start(setup_server_logging)
app.before_server_stop(remove_server_logging)
@@ -0,0 +1,93 @@
import inspect
import warnings
import yaml
class OpenAPIDocstringParser:
def __init__(self, docstring: str):
"""
Args:
docstring (str): docstring of function to be parsed
"""
if docstring is None:
docstring = ""
self.docstring = inspect.cleandoc(docstring)
def to_openAPI_2(self) -> dict:
"""
Returns:
json style dict: dict to be read for the path by swagger 2.0 UI
"""
raise NotImplementedError()
def to_openAPI_3(self) -> dict:
"""
Returns:
json style dict: dict to be read for the path by swagger 3.0.0 UI
"""
raise NotImplementedError()
class YamlStyleParametersParser(OpenAPIDocstringParser):
def _parse_no_yaml(self, doc: str) -> dict:
"""
Args:
doc (str): section of doc before yaml, or full section of doc
Returns:
json style dict: dict to be read for the path by swagger UI
"""
# clean again in case further indentation can be removed,
# usually this do nothing...
doc = inspect.cleandoc(doc)
if len(doc) == 0:
return {}
lines = doc.split("\n")
if len(lines) == 1:
return {"summary": lines[0]}
else:
summary = lines.pop(0)
# remove empty lines at the beginning of the description
while len(lines) and lines[0].strip() == "":
lines.pop(0)
if len(lines) == 0:
return {"summary": summary}
else:
# use html tag to preserve linebreaks
return {"summary": summary, "description": "<br>".join(lines)}
def _parse_yaml(self, doc: str) -> dict:
"""
Args:
doc (str): section of doc detected as openapi yaml
Returns:
json style dict: dict to be read for the path by swagger UI
Warns:
UserWarning if the yaml couldn't be parsed
"""
try:
return yaml.safe_load(doc)
except Exception as e:
warnings.warn(f"error parsing openAPI yaml, ignoring it. ({e})")
return {}
def _parse_all(self) -> dict:
if "openapi:\n" not in self.docstring:
return self._parse_no_yaml(self.docstring)
predoc, yamldoc = self.docstring.split("openapi:\n", 1)
conf = self._parse_no_yaml(predoc)
conf.update(self._parse_yaml(yamldoc))
return conf
def to_openAPI_2(self) -> dict:
return self._parse_all()
def to_openAPI_3(self) -> dict:
return self._parse_all()
@@ -0,0 +1,261 @@
import inspect
from functools import lru_cache, partial
from os.path import abspath, dirname, realpath
from sanic import Request
from sanic.blueprints import Blueprint
from sanic.config import Config
from sanic.log import logger
from sanic.response import file, html, json
from sanic_ext.config import PRIORITY
from sanic_ext.extensions.openapi.builders import (
OperationStore,
SpecificationBuilder,
)
from ...utils.route import (
clean_route_name,
get_all_routes,
get_blueprinted_routes,
)
@lru_cache
def get_oauth2_redirect_html(version: str):
import urllib
response = urllib.request.urlopen(
f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{version}/"
"oauth2-redirect.html"
).read()
return response.decode("utf-8")
def oauth2_handler(request: Request, version: str):
return html(get_oauth2_redirect_html(version))
def blueprint_factory(config: Config):
bp = Blueprint("openapi", url_prefix=config.OAS_URL_PREFIX)
dir_path = dirname(realpath(__file__))
dir_path = abspath(dir_path + "/ui")
for ui in ("redoc", "swagger"):
if getattr(config, f"OAS_UI_{ui}".upper()):
path = getattr(config, f"OAS_PATH_TO_{ui}_HTML".upper())
uri = getattr(config, f"OAS_URI_TO_{ui}".upper())
version = getattr(config, f"OAS_UI_{ui}_VERSION".upper(), "")
html_title = getattr(config, f"OAS_UI_{ui}_HTML_TITLE".upper())
custom_css = getattr(config, f"OAS_UI_{ui}_CUSTOM_CSS".upper())
html_path = path if path else f"{dir_path}/{ui}.html"
with open(html_path) as f:
page = f.read()
def index(
request: Request, page: str, html_title: str, custom_css: str
):
prefix = (
request.app.url_for("openapi.index", _external=True)
if getattr(request.app.config, "SERVER_NAME", None)
else getattr(request.app.config, "OAS_URL_PREFIX")
).rstrip("/")
return html(
page.replace("__VERSION__", version)
.replace("__URL_PREFIX__", prefix)
.replace("__HTML_TITLE__", html_title)
.replace("__HTML_CUSTOM_CSS__", custom_css)
)
bp.add_route(
partial(
index,
page=page,
html_title=html_title,
custom_css=custom_css,
),
uri,
name=ui,
)
if config.OAS_UI_DEFAULT and config.OAS_UI_DEFAULT == ui:
bp.add_route(
partial(
index,
page=page,
html_title=html_title,
custom_css=custom_css,
),
"",
name="index",
)
if ui == "swagger":
oauth2_redirect_uri = getattr(
config, "OAS_UI_SWAGGER_OAUTH2_REDIRECT"
)
bp.add_route(
partial(oauth2_handler, version=version),
oauth2_redirect_uri,
name="oauth2-redirect",
)
@bp.get(config.OAS_URI_TO_JSON)
async def spec(request: Request):
if config.OAS_CUSTOM_FILE:
return await file(config.OAS_CUSTOM_FILE)
return json(SpecificationBuilder().build(request.app).serialize())
if config.OAS_UI_SWAGGER:
@bp.get(config.OAS_URI_TO_CONFIG)
def openapi_config(request: Request):
return json(request.app.config.SWAGGER_UI_CONFIGURATION)
@bp.before_server_start(priority=PRIORITY)
def build_spec(app, loop):
specification = SpecificationBuilder()
# --------------------------------------------------------------- #
# Blueprint Tags
# --------------------------------------------------------------- #
for blueprint_name, handler in get_blueprinted_routes(app):
operation = OperationStore()[handler]
if not operation.tags:
operation.tag(blueprint_name)
# --------------------------------------------------------------- #
# Operations
# --------------------------------------------------------------- #
for (
uri,
route_name,
route_parameters,
method_handlers,
host,
) in get_all_routes(app, bp.url_prefix):
# --------------------------------------------------------------- #
# Methods
# --------------------------------------------------------------- #
for method, _handler in method_handlers:
if (
(method == "OPTIONS" and app.config.OAS_IGNORE_OPTIONS)
or (method == "HEAD" and app.config.OAS_IGNORE_HEAD)
or method == "TRACE"
):
continue
if hasattr(_handler, "view_class"):
_handler = getattr(_handler.view_class, method.lower())
store = OperationStore()
if (
_handler not in store
and (func := getattr(_handler, "__func__", None))
and func in store
):
_handler = func
operation = store[_handler]
if operation._exclude or "openapi" in operation.tags:
continue
docstring = inspect.getdoc(_handler)
if (
docstring
and app.config.OAS_AUTODOC
and operation._allow_autodoc
):
operation.autodoc(docstring)
operation._default["operationId"] = (
f"{method.lower()}~{route_name}"
)
operation._default["summary"] = clean_route_name(route_name)
if host:
if "servers" not in operation._default:
operation._default["servers"] = []
operation._default["servers"].append({"url": f"//{host}"})
for _parameter in route_parameters:
if any(
param.fields["name"] == _parameter.name
for param in operation.parameters
):
continue
kwargs = {}
if operation._autodoc and (
parameters := operation._autodoc.get("parameters")
):
for param in parameters:
if param.pop("name", None) == _parameter.name:
kwargs["description"] = param.get(
"description"
)
kwargs["required"] = param.get("required")
if schema := param.get("schema"):
logger.warning(
f"Ignoring the schema {schema} in "
f"'{route_name}' for "
f"'{_parameter.name}'. "
"Instead of using the definition in "
"docstring definition, Sanic will use "
"the actual schema defined for this "
"parameter on the route."
)
break
operation.parameter(
_parameter.name, _parameter.cast, "path", **kwargs
)
operation._app = app
specification.operation(uri, method, operation)
add_static_info_to_spec_from_config(app, specification)
return bp
def add_static_info_to_spec_from_config(app, specification):
"""
Reads app.config and sets attributes to specification according to the
desired values.
Modifies specification in-place and returns None
"""
specification._do_describe(
getattr(app.config, "API_TITLE", "API"),
getattr(app.config, "API_VERSION", "1.0.0"),
getattr(app.config, "API_DESCRIPTION", None),
getattr(app.config, "API_TERMS_OF_SERVICE", None),
)
specification._do_license(
getattr(app.config, "API_LICENSE_NAME", None),
getattr(app.config, "API_LICENSE_URL", None),
)
specification._do_contact(
getattr(app.config, "API_CONTACT_NAME", None),
getattr(app.config, "API_CONTACT_URL", None),
getattr(app.config, "API_CONTACT_EMAIL", None),
)
schemes = getattr(app.config, "API_SCHEMES", ["http"]) or ["http"]
if isinstance(schemes, str):
schemes = [s.strip() for s in schemes.split(",")]
for scheme in schemes:
host = getattr(app.config, "API_HOST", None)
basePath = getattr(app.config, "API_BASEPATH", "")
if host is None or basePath is None:
continue
specification.url(f"{scheme}://{host}/{basePath}")
@@ -0,0 +1,446 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Sequence
from typing import TYPE_CHECKING, Optional, Union, cast
from sanic_ext.extensions.openapi.constants import (
SecuritySchemeAuthorization,
SecuritySchemeLocation,
SecuritySchemeType,
)
from ...utils.route import remove_nulls, remove_nulls_from_kwargs
from .autodoc import YamlStyleParametersParser
from .definitions import (
Any,
Components,
Contact,
ExternalDocumentation,
Flows,
Info,
License,
OpenAPI,
Operation,
Parameter,
PathItem,
RequestBody,
Response,
SecurityRequirement,
SecurityScheme,
Server,
Tag,
)
if TYPE_CHECKING:
from sanic import Sanic
class OperationBuilder:
summary: str
description: str
operationId: str
requestBody: RequestBody
externalDocs: ExternalDocumentation
tags: list[str]
security: list[Any]
parameters: list[Parameter]
responses: dict[str, Response]
callbacks: list[str] # TODO
deprecated: bool = False
def __init__(self):
self.tags = []
self.security = []
self.parameters = []
self.responses = {}
self._default = {}
self._autodoc = None
self._exclude = False
self._allow_autodoc = True
self._app: Optional[Sanic] = None
def name(self, value: str):
self.operationId = value
def describe(self, summary: str = None, description: str = None):
if summary:
self.summary = summary
if description:
self.description = description
def document(self, url: str, description: str = None):
self.externalDocs = ExternalDocumentation.make(url, description)
def tag(self, *args: str):
for arg in args:
if isinstance(arg, Tag):
arg = arg.fields["name"]
self.tags.append(arg)
def deprecate(self):
self.deprecated = True
def body(self, content: Any, **kwargs):
self.requestBody = RequestBody.make(content, **kwargs)
def parameter(
self, name: str, schema: Any, location: str = "query", **kwargs
):
self.parameters.append(
Parameter.make(name, schema, location, **kwargs)
)
def response(
self, status, content: Any = None, description: str = None, **kwargs
):
response = Response.make(content, description, **kwargs)
if status in self.responses:
self.responses[status]._fields["content"].update(
response.fields["content"]
)
else:
self.responses[status] = response
def secured(self, *args, **kwargs):
if not kwargs and len(args) == 1 and isinstance(args[0], dict):
items = args[0]
else:
items = {**{v: [] for v in args}, **kwargs}
gates = {}
for name, params in items.items():
gate = name.__name__ if isinstance(name, type) else name
gates[gate] = params
self.security.append(gates)
def disable_autodoc(self):
self._allow_autodoc = False
def build(self):
operation_dict = self._build_merged_dict()
if "responses" not in operation_dict:
# todo -- look into more consistent default response format
operation_dict["responses"] = {"default": {"description": "OK"}}
return Operation(**operation_dict)
def _build_merged_dict(self):
defined_dict = self.__dict__.copy()
autodoc_dict = self._autodoc or {}
default_dict = self._default
merged_dict = {}
for d in (default_dict, autodoc_dict, defined_dict):
cleaned = {
k: v for k, v in d.items() if v and not k.startswith("_")
}
merged_dict.update(cleaned)
return merged_dict
def autodoc(self, docstring: str):
y = YamlStyleParametersParser(docstring)
self._autodoc = y.to_openAPI_3()
def exclude(self, flag: bool = True):
self._exclude = flag
class OperationStore(defaultdict):
_singleton = None
def __new__(cls) -> Any:
if not cls._singleton:
cls._singleton = super().__new__(cls)
return cls._singleton
def __init__(self):
super().__init__(OperationBuilder)
@classmethod
def reset(cls):
cls._singleton = None
class SpecificationBuilder:
_urls: list[str]
_title: str
_version: str
_description: Optional[str]
_terms: Optional[str]
_contact: Contact
_license: License
_paths: dict[str, dict[str, OperationBuilder]]
_tags: dict[str, Tag]
_security: list[SecurityRequirement]
_components: dict[str, Any]
_servers: list[Server]
# _components: ComponentsBuilder
# deliberately not included
_singleton: Optional[SpecificationBuilder] = None
def __new__(cls) -> SpecificationBuilder:
if not cls._singleton:
cls._singleton = super().__new__(cls)
cls._setup_instance(cls._singleton)
return cast(SpecificationBuilder, cls._singleton)
@classmethod
def _setup_instance(cls, instance):
instance._components = defaultdict(dict)
instance._contact = None
instance._description = None
instance._external = None
instance._license = None
instance._paths = defaultdict(dict)
instance._servers = []
instance._tags = {}
instance._security = []
instance._terms = None
instance._title = None
instance._urls = []
instance._version = None
@classmethod
def reset(cls):
cls._singleton = None
@property
def tags(self):
return self._tags
@property
def security(self):
return self._security
def url(self, value: str):
self._urls.append(value)
def describe(
self,
title: str,
version: str,
description: Optional[str] = None,
terms: Optional[str] = None,
):
self._title = title
self._version = version
self._description = description
self._terms = terms
def _do_describe(
self,
title: str,
version: str,
description: Optional[str] = None,
terms: Optional[str] = None,
):
if any([self._title, self._version, self._description, self._terms]):
return
self.describe(title, version, description, terms)
def tag(self, name: str, description: Optional[str] = None, **kwargs):
self._tags[name] = Tag(name, description=description, **kwargs)
def external(self, url: str, description: Optional[str] = None, **kwargs):
self._external = ExternalDocumentation(url, description=description)
def secured(
self,
name: str = None,
value: Optional[Union[str, Sequence[str]]] = None,
):
if value is None:
value = []
elif isinstance(value, str):
value = [value]
else:
value = list(value)
self._security.append(SecurityRequirement(name=name, value=value))
def contact(self, name: str = None, url: str = None, email: str = None):
kwargs = remove_nulls_from_kwargs(name=name, url=url, email=email)
self._contact = Contact(**kwargs)
def _do_contact(
self, name: str = None, url: str = None, email: str = None
):
if self._contact:
return
self.contact(name, url, email)
def license(self, name: str = None, url: str = None):
if name is not None:
self._license = License(name, url=url)
def _do_license(self, name: str = None, url: str = None):
if self._license:
return
self.license(name, url)
def operation(self, path: str, method: str, operation: OperationBuilder):
for _tag in operation.tags:
if _tag in self._tags.keys():
continue
self._tags[_tag] = Tag(_tag)
self._paths[path][method.lower()] = operation
def add_component(self, location: str, name: str, obj: Any):
self._components[location].update({name: obj})
def has_component(self, location: str, name: str) -> bool:
return name in self._components.get(location, {})
def add_security_scheme(
self,
ident: str,
type: Union[str, SecuritySchemeType],
*,
bearer_format: Optional[str] = None,
description: Optional[str] = None,
flows: Optional[Union[Flows, dict[str, Any]]] = None,
location: Union[
str, SecuritySchemeLocation
] = SecuritySchemeLocation.HEADER,
name: str = "authorization",
openid_connect_url: Optional[str] = None,
scheme: Union[
str, SecuritySchemeAuthorization
] = SecuritySchemeAuthorization.BEARER,
):
if isinstance(type, str):
type = SecuritySchemeType(type)
if isinstance(location, str):
location = SecuritySchemeLocation(location)
kwargs: dict[str, Any] = {"type": type, "description": description}
if type is SecuritySchemeType.API_KEY:
kwargs["location"] = location
kwargs["name"] = name
elif type is SecuritySchemeType.HTTP:
kwargs["scheme"] = scheme
kwargs["bearerFormat"] = bearer_format
elif type is SecuritySchemeType.OAUTH2:
kwargs["flows"] = flows
elif type is SecuritySchemeType.OPEN_ID_CONNECT:
kwargs["openIdConnectUrl"] = openid_connect_url
self.add_component(
"securitySchemes",
ident,
SecurityScheme(**kwargs),
) # type: ignore
def raw(self, data):
if "info" in data:
self.describe(
data["info"].get("title"),
data["info"].get("version"),
data["info"].get("description"),
data["info"].get("terms"),
)
if "servers" in data:
for server in data["servers"]:
self._servers.append(Server(**server))
if "paths" in data:
self._paths.update(data["paths"])
if "components" in data:
for location, component in data["components"].items():
self._components[location].update(component)
if "security" in data:
for security in data["security"]:
if not security:
self.secured()
else:
for key, value in security.items():
self.secured(key, value)
if "tags" in data:
for tag in data["tags"]:
self.tag(**tag)
if "externalDocs" in data:
self.external(**data["externalDocs"])
def build(self, app: Sanic) -> OpenAPI:
info = self._build_info()
paths = self._build_paths(app)
tags = self._build_tags()
security = self._build_security()
url_servers = getattr(self, "_urls", None)
servers = self._servers
existing = [
server.fields["url"].strip("/") for server in self._servers
]
if url_servers is not None:
for url_server in url_servers:
if url_server.strip("/") not in existing:
servers.append(Server(url=url_server))
components = (
Components(**self._components) if self._components else None
)
return OpenAPI(
info,
paths,
tags=tags,
servers=servers,
security=security,
components=components,
externalDocs=self._external,
)
def _build_info(self) -> Info:
kwargs = remove_nulls(
{
"description": self._description,
"termsOfService": self._terms,
"license": self._license,
"contact": self._contact,
},
deep=False,
)
return Info(self._title, self._version, **kwargs)
def _build_tags(self):
return [self._tags[k] for k in self._tags]
def _build_paths(self, app: Sanic) -> dict:
paths = {}
for path, operations in self._paths.items():
paths[path] = PathItem(
**{
k: v if isinstance(v, dict) else v.build()
for k, v in operations.items()
if isinstance(v, dict) or v._app is app
}
)
return paths
def _build_security(self):
return [
(
{sec.fields["name"]: sec.fields["value"]}
if sec.fields["name"] is not None
else {}
)
for sec in self.security
]
@@ -0,0 +1,36 @@
from enum import Enum, auto
class BaseEnum(Enum):
def _generate_next_value_(name, start, count, last_values):
parts = name.split("_")
return parts[0].lower() + "".join(part.title() for part in parts[1:])
class SecuritySchemeType(BaseEnum):
API_KEY = auto()
HTTP = auto()
OAUTH2 = auto()
OPEN_ID_CONNECT = auto()
class SecuritySchemeLocation(BaseEnum):
QUERY = auto()
HEADER = auto()
COOKIE = auto()
class SecuritySchemeAuthorization(BaseEnum):
def _generate_next_value_(name, start, count, last_values):
return name.title()
BASIC = auto()
BEARER = auto()
DIGEST = auto()
HOBA = "HOBA"
MUTUAL = auto()
NEGOTIATE = auto()
OAUTH = "OAuth"
SCRAM_SHA_1 = "SCRAM-SHA-1"
SCRAM_SHA_256 = "SCRAM-SHA-256"
VAPID = "vapid"
@@ -0,0 +1,455 @@
"""
Classes defined from the OpenAPI 3.0 specifications.
I.e., the objects described https://swagger.io/docs/specification
"""
from __future__ import annotations
from inspect import isclass
from typing import (
Any,
Literal,
Optional,
Union,
get_type_hints,
)
from sanic.exceptions import SanicException
from sanic_ext.utils.typing import (
contains_annotations,
is_msgspec,
is_pydantic,
)
from .types import Definition, Schema
class Reference(Schema):
def __init__(self, value):
super().__init__(**{"$ref": value})
def guard(self, fields: dict[str, Any]):
return fields
class Contact(Definition):
name: str
url: str
email: str
class License(Definition):
name: str
url: str
def __init__(self, name: str, **kwargs):
super().__init__(name=name, **kwargs)
class Info(Definition):
title: str
description: str
termsOfService: str
contact: Contact
license: License
version: str
def __init__(self, title: str, version: str, **kwargs):
super().__init__(title=title, version=version, **kwargs)
class Example(Definition):
summary: str
description: str
value: Any
externalValue: str
def __init__(self, value: Any = None, **kwargs):
super().__init__(value=value, **kwargs)
@staticmethod
def make(value: Any, **kwargs):
return Example(Schema.make(value), **kwargs)
@staticmethod
def external(value: Any, **kwargs):
return Example(externalValue=value, **kwargs)
class MediaType(Definition):
schema: Schema
example: Any
def __init__(self, schema: Union[Schema, dict[str, Any]], **kwargs):
if isinstance(schema, dict) and contains_annotations(schema):
schema = Schema.make(schema)
super().__init__(schema=schema, **kwargs)
@staticmethod
def make(value: Any):
if isinstance(value, dict):
kwargs = {}
if "schema" in value:
kwargs = {**value}
value = kwargs.pop("schema")
return MediaType(value, **kwargs)
# See https://github.com/sanic-org/sanic-ext/issues/152
# The following lines will automatically inject pydantic models as
# components if that feature is desired. Until that decision is made
# this commented out code will remain.
# elif isclass(value) and is_pydantic(value):
# return MediaType(Component(value))
return MediaType(Schema.make(value))
@staticmethod
def all(content: Any):
media_types = (
content if isinstance(content, dict) else {"*/*": content or {}}
)
return {x: MediaType.make(v) for x, v in media_types.items()}
class Response(Definition):
content: Union[Any, dict[str, Union[Any, MediaType]]]
description: Optional[str]
status: Union[Literal["default"], int]
__nullable__ = None
__ignore__ = ["status"]
def __init__(
self,
content: Optional[Union[Any, dict[str, Union[Any, MediaType]]]] = None,
status: Union[Literal["default"], int] = "default",
description: Optional[str] = None,
**kwargs,
):
super().__init__(
content=content,
status=status,
description=description,
**kwargs,
)
@staticmethod
def make(content, description: Optional[str] = None, **kwargs):
if not description:
description = "Default Response"
return Response(
MediaType.all(content), description=description, **kwargs
)
class RequestBody(Definition):
description: Optional[str]
required: Optional[bool]
content: Union[Any, dict[str, Union[Any, MediaType]]]
__nullable__ = None
def __init__(
self,
content: Union[Any, dict[str, Union[Any, MediaType]]],
required: Optional[bool] = None,
description: Optional[str] = None,
**kwargs,
):
"""Can be initialized with content in one of a few ways:
RequestBody(SomeModel)
RequestBody({"application/json": SomeModel})
RequestBody({"application/json": {"name": str}})
"""
super().__init__(
content=content,
required=required,
description=description,
**kwargs,
)
@staticmethod
def make(content: Any, **kwargs):
return RequestBody(MediaType.all(content), **kwargs)
class ExternalDocumentation(Definition):
url: str
description: str
__nullable__ = None
def __init__(self, url: str, description=None):
super().__init__(url=url, description=description)
@staticmethod
def make(url: str, description: Optional[str] = None):
return ExternalDocumentation(url, description)
class Header(Definition):
name: str
description: str
externalDocs: ExternalDocumentation
def __init__(self, url: str, description=None):
super().__init__(url=url, description=description)
@staticmethod
def make(url: str, description: Optional[str] = None):
return Header(url, description)
class Parameter(Definition):
name: str
schema: Union[type, Schema]
location: str
description: Optional[str]
required: Optional[bool]
deprecated: Optional[bool]
allowEmptyValue: Optional[bool]
__nullable__ = None
def __init__(
self,
name: str,
schema: Union[type, Schema] = str,
location: str = "query",
description: Optional[str] = None,
required: Optional[bool] = None,
deprecated: Optional[bool] = None,
allowEmptyValue: Optional[bool] = None,
**kwargs,
):
super().__init__(
name=name,
schema=schema,
location=location,
description=description,
required=required,
deprecated=deprecated,
allowEmptyValue=allowEmptyValue,
**kwargs,
)
@property
def fields(self):
values = super().fields
if "location" in values:
values["in"] = values.pop("location")
return values
@staticmethod
def make(name: str, schema: type, location: str, **kwargs):
if location == "path" and "required" not in kwargs:
kwargs["required"] = True
return Parameter(name, Schema.make(schema), location, **kwargs)
class Operation(Definition):
tags: list[str]
summary: str
description: str
operationId: str
requestBody: RequestBody
externalDocs: ExternalDocumentation
parameters: list[Parameter]
responses: dict[str, Response]
security: dict[str, list[str]]
callbacks: list[str] # TODO
deprecated: bool
servers: list[dict[str, str]]
class PathItem(Definition):
summary: str
description: str
get: Operation
put: Operation
post: Operation
delete: Operation
options: Operation
head: Operation
patch: Operation
trace: Operation
class Flow(Definition):
authorizationUrl: str
tokenUrl: str
refreshUrl: str
scopes: dict[str, str]
class Flows(Definition):
implicit: Flow
password: Flow
clientCredentials: Flow
authorizationCode: Flow
class SecurityRequirement(Definition):
name: str
value: list[str]
class SecurityScheme(Definition):
type: str
bearerFormat: str
description: str
flows: Flows
location: str
name: str
openIdConnectUrl: str
scheme: str
__nullable__ = None
def __init__(self, type: str, **kwargs):
super().__init__(type=type, **kwargs)
@property
def fields(self):
values = super().fields
if "location" in values:
values["in"] = values.pop("location")
return values
@staticmethod
def make(_type: str, cls: type, **kwargs):
params: dict[str, Any] = getattr(cls, "__dict__", {})
return SecurityScheme(_type, **params, **kwargs)
class ServerVariable(Definition):
default: str
description: str
enum: list[str]
def __init__(self, default: str, **kwargs):
super().__init__(default=default, **kwargs)
class Server(Definition):
url: str
description: str
variables: dict[str, ServerVariable]
__nullable__ = None
def __init__(
self,
url: str,
description: Optional[str] = None,
variables: Optional[dict[str, Any]] = None,
):
super().__init__(
url=url, description=description, variables=variables or {}
)
class Tag(Definition):
name: str
description: str
externalDocs: ExternalDocumentation
def __init__(self, name: str, **kwargs):
super().__init__(name=name, **kwargs)
class Components(Definition):
# This class is not being used in sanic-openapi right now, but the
# definition is kept here to keep in close accordance with the openapi
# spec, in case it is desired to be added later.
schemas: dict[str, Schema]
responses: dict[str, Response]
parameters: dict[str, Parameter]
examples: dict[str, Example]
requestBodies: dict[str, RequestBody]
headers: dict[str, Header]
securitySchemes: dict[str, SecurityScheme]
links: dict[str, Schema] # TODO
callbacks: dict[str, Schema] # TODO
def Component(
obj: Any, *, name: str = "", field: str = "schemas"
) -> Reference:
hints = get_type_hints(Components)
if field not in hints:
raise AttributeError(
f"Unknown field '{field}'. Must be a valid field per OAS3 "
"requirements. See "
"https://swagger.io/specification/#components-object."
)
if not isclass(obj) and not name:
raise SanicException(
f"Components {obj} must be created with a declared name"
)
if not name:
name = obj.__name__
from sanic_ext.extensions.openapi.builders import SpecificationBuilder
spec = SpecificationBuilder()
refval = f"#/components/{field}/{name}"
ref = Reference(refval)
if not spec.has_component(field, name):
prop_info = hints[field]
type_ = prop_info.__args__[1]
if is_msgspec(obj):
import msgspec
_, definitions = msgspec.json.schema_components(
[obj], ref_template="#/components/schemas/{name}"
)
if definitions:
for key, value in definitions.items():
spec.add_component(field, key, value)
elif is_pydantic(obj):
try:
schema = obj.schema
except AttributeError:
schema = obj.__pydantic_model__.schema
component = schema(ref_template="#/components/schemas/{model}")
definitions = component.pop("definitions", None)
if definitions:
for key, value in definitions.items():
spec.add_component(field, key, value)
spec.add_component(field, name, component)
else:
component = (
type_.make(obj) if hasattr(type_, "make") else type_(obj)
)
spec.add_component(field, name, component)
return ref
class OpenAPI(Definition):
openapi: str
info: Info
servers: list[Server]
paths: dict[str, PathItem]
components: Components
security: dict[str, SecurityScheme]
tags: list[Tag]
externalDocs: ExternalDocumentation
def __init__(self, info: Info, paths: dict[str, PathItem], **kwargs):
use = {k: v for k, v in kwargs.items() if v is not None}
super().__init__(openapi="3.0.3", info=info, paths=paths, **use)
@@ -0,0 +1,43 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from sanic_ext.extensions.openapi.builders import SpecificationBuilder
from ..base import Extension
from .blueprint import blueprint_factory
if TYPE_CHECKING:
from sanic_ext import Extend
class OpenAPIExtension(Extension):
name = "openapi"
def startup(self, bootstrap: Extend) -> None:
if self.app.config.OAS:
self.bp = blueprint_factory(self.app.config)
self.app.blueprint(self.bp)
bootstrap._openapi = SpecificationBuilder()
def label(self):
if self.app.config.OAS:
return self._make_url()
return ""
def included(self):
return self.config.OAS
def _make_url(self):
name = f"{self.bp.name}.index"
_server = (
None
if "SERVER_NAME" in self.app.config
else self.app.serve_location
) or None
_external = bool(_server) or "SERVER_NAME" in self.app.config
return (
f"{self.app.url_for(name, _external=_external, _server=_server)}"
)
@@ -0,0 +1,534 @@
"""
This module provides decorators which append
documentation to OperationStore() and components created in the blueprints.
"""
from collections.abc import Sequence
from functools import wraps
from inspect import isawaitable, isclass
from typing import (
Any,
Callable,
Literal,
Optional,
TypeVar,
Union,
overload,
)
from sanic import Blueprint
from sanic.exceptions import InvalidUsage, SanicException
from sanic_ext.extensions.openapi import definitions
from sanic_ext.extensions.openapi.builders import (
OperationStore,
SpecificationBuilder,
)
from sanic_ext.extensions.openapi.definitions import Component
from sanic_ext.extensions.openapi.types import (
Array,
Binary,
Boolean,
Byte,
Date,
DateTime,
Double,
Email,
Float,
Integer,
Long,
Object,
Password,
Schema,
String,
Time,
)
from sanic_ext.extras.validation.setup import do_validation, generate_schema
from sanic_ext.utils.extraction import extract_request
__all__ = (
"definitions",
"body",
"component",
"definition",
"deprecated",
"description",
"document",
"exclude",
"no_autodoc",
"operation",
"parameter",
"response",
"secured",
"summary",
"tag",
"Array",
"Binary",
"Boolean",
"Byte",
"Component",
"Date",
"DateTime",
"Double",
"Email",
"Float",
"Integer",
"Long",
"Object",
"Password",
"String",
"Time",
)
def _content_or_component(content):
if isclass(content):
spec = SpecificationBuilder()
if spec._components["schemas"].get(content.__name__):
content = definitions.Component(content)
return content
@overload
def exclude(flag: bool = True, *, bp: Blueprint) -> None: ...
@overload
def exclude(flag: bool = True) -> Callable: ...
def exclude(flag: bool = True, *, bp: Optional[Blueprint] = None):
if bp:
for route in bp.routes:
exclude(flag)(route.handler)
return
def inner(func):
OperationStore()[func].exclude(flag)
return func
return inner
T = TypeVar("T")
def operation(name: str) -> Callable[[T], T]:
def inner(func):
OperationStore()[func].name(name)
return func
return inner
def summary(text: str) -> Callable[[T], T]:
def inner(func):
OperationStore()[func].describe(summary=text)
return func
return inner
def description(text: str) -> Callable[[T], T]:
def inner(func):
OperationStore()[func].describe(description=text)
return func
return inner
def document(
url: Union[str, definitions.ExternalDocumentation],
description: Optional[str] = None,
) -> Callable[[T], T]:
if isinstance(url, definitions.ExternalDocumentation):
description = url.fields["description"]
url = url.fields["url"]
def inner(func):
OperationStore()[func].document(url, description)
return func
return inner
def tag(*args: Union[str, definitions.Tag]) -> Callable[[T], T]:
def inner(func):
OperationStore()[func].tag(*args)
return func
return inner
def deprecated(maybe_func=None) -> Callable[[T], T]:
def inner(func):
OperationStore()[func].deprecate()
return func
return inner(maybe_func) if maybe_func else inner
def no_autodoc(maybe_func=None) -> Callable[[T], T]:
def inner(func):
OperationStore()[func].disable_autodoc()
return func
return inner(maybe_func) if maybe_func else inner
def body(
content: Any,
*,
validate: bool = False,
body_argument: str = "body",
**kwargs,
) -> Callable[[T], T]:
body_content = _content_or_component(content)
params = {**kwargs}
validation_schema = None
if isinstance(body_content, definitions.RequestBody):
params = {**body_content.fields, **params}
body_content = params.pop("content")
if validate:
if callable(validate):
model = validate
else:
model = body_content
validation_schema = generate_schema(body_content)
def inner(func):
@wraps(func)
async def handler(*handler_args, **handler_kwargs):
request = extract_request(*handler_args)
if validate:
try:
data = request.json
allow_multiple = False
allow_coerce = False
except InvalidUsage:
data = request.form
allow_multiple = True
allow_coerce = True
await do_validation(
model=model,
data=data,
schema=validation_schema,
request=request,
kwargs=handler_kwargs,
body_argument=body_argument,
allow_multiple=allow_multiple,
allow_coerce=allow_coerce,
)
retval = func(*handler_args, **handler_kwargs)
if isawaitable(retval):
retval = await retval
return retval
if func in OperationStore():
OperationStore()[handler] = OperationStore().pop(func)
OperationStore()[handler].body(body_content, **params)
return handler
return inner
@overload
def parameter(
*,
parameter: definitions.Parameter,
**kwargs,
) -> Callable[[T], T]: ...
@overload
def parameter(
name: None,
schema: None,
location: None,
parameter: definitions.Parameter,
**kwargs,
) -> Callable[[T], T]: ...
@overload
def parameter(
name: str,
schema: Optional[Union[type, Schema]] = None,
location: Optional[str] = None,
parameter: None = None,
**kwargs,
) -> Callable[[T], T]: ...
def parameter(
name: Optional[str] = None,
schema: Optional[Union[type, Schema]] = None,
location: Optional[str] = None,
parameter: Optional[definitions.Parameter] = None,
**kwargs,
) -> Callable[[T], T]:
if parameter:
if name or schema or location:
raise SanicException(
"When using a parameter object, you cannot pass "
"other arguments."
)
if not schema:
schema = str
if not location:
location = "query"
def inner(func: Callable):
if parameter:
# Temporary solution convert in to location,
# need to be changed later.
fields = dict(parameter.fields)
if "in" in fields:
fields["location"] = fields.pop("in")
OperationStore()[func].parameter(**fields)
else:
OperationStore()[func].parameter(name, schema, location, **kwargs)
return func
return inner
def response(
status: Union[Literal["default"], int] = "default",
content: Any = str,
description: Optional[str] = None,
*,
response: Optional[definitions.Response] = None,
**kwargs,
) -> Callable[[T], T]:
if response:
if (
status != "default"
or content is not str
or description is not None
):
raise SanicException(
"When using a response object, you cannot pass "
"other arguments."
)
status = response.fields["status"]
content = response.fields["content"]
description = response.fields["description"]
def inner(func):
OperationStore()[func].response(status, content, description, **kwargs)
return func
return inner
def secured(*args, **kwargs) -> Callable[[T], T]:
def inner(func):
OperationStore()[func].secured(*args, **kwargs)
return func
return inner
Model = TypeVar("Model")
def component(
model: Optional[Model] = None,
*,
name: Optional[str] = None,
field: str = "schemas",
) -> Callable[[T], T]:
def wrap(m):
return component(m, name=name, field=field)
if not model:
return wrap
params = {}
if name:
params["name"] = name
if field:
params["field"] = field
definitions.Component(model, **params)
return model
def definition(
*,
exclude: Optional[bool] = None,
operation: Optional[str] = None,
summary: Optional[str] = None,
description: Optional[str] = None,
document: Optional[Union[str, definitions.ExternalDocumentation]] = None,
tag: Optional[
Union[
Union[str, definitions.Tag], Sequence[Union[str, definitions.Tag]]
]
] = None,
deprecated: bool = False,
body: Optional[Union[dict[str, Any], definitions.RequestBody, Any]] = None,
parameter: Optional[
Union[
Union[dict[str, Any], definitions.Parameter, str],
list[Union[dict[str, Any], definitions.Parameter, str]],
]
] = None,
response: Optional[
Union[
Union[dict[str, Any], definitions.Response, Any],
list[Union[dict[str, Any], definitions.Response]],
]
] = None,
secured: Optional[dict[str, Any]] = None,
validate: bool = False,
body_argument: str = "body",
) -> Callable[[T], T]:
validation_schema = None
body_content = None
def inner(func):
nonlocal validation_schema
nonlocal body_content
glbl = globals()
if body:
kwargs = {}
content = body
if isinstance(content, definitions.RequestBody):
kwargs = content.fields
elif isinstance(content, dict):
if "content" in content:
kwargs = content
else:
kwargs["content"] = content
else:
content = _content_or_component(content)
kwargs["content"] = content
if validate:
kwargs["validate"] = validate
kwargs["body_argument"] = body_argument
func = glbl["body"](**kwargs)(func)
if exclude is not None:
func = glbl["exclude"](exclude)(func)
if operation:
func = glbl["operation"](operation)(func)
if summary:
func = glbl["summary"](summary)(func)
if description:
func = glbl["description"](description)(func)
if document:
kwargs = {}
if isinstance(document, str):
kwargs["url"] = document
else:
kwargs["url"] = document.fields["url"]
kwargs["description"] = document.fields["description"]
func = glbl["document"](**kwargs)(func)
if tag:
taglist = []
op = (
"extend"
if isinstance(tag, (list, tuple, set, frozenset))
else "append"
)
getattr(taglist, op)(tag)
func = glbl["tag"](*taglist)(func)
if deprecated:
func = glbl["deprecated"]()(func)
if parameter:
paramlist = []
op = (
"extend"
if isinstance(parameter, (list, tuple, set, frozenset))
else "append"
)
getattr(paramlist, op)(parameter)
for param in paramlist:
kwargs = {}
if isinstance(param, definitions.Parameter):
kwargs = param.fields
if "in" in kwargs:
kwargs["location"] = kwargs.pop("in")
elif isinstance(param, dict) and "name" in param:
kwargs = param
elif isinstance(param, str):
kwargs["name"] = param
else:
raise SanicException(
"parameter must be a Parameter instance, a string, or "
"a dictionary containing at least 'name'."
)
if "schema" not in kwargs:
kwargs["schema"] = str
func = glbl["parameter"](**kwargs)(func)
if response:
resplist = []
op = (
"extend"
if isinstance(response, (list, tuple, set, frozenset))
else "append"
)
getattr(resplist, op)(response)
if len(resplist) > 1 and any(
not isinstance(item, definitions.Response)
and not isinstance(item, dict)
for item in resplist
):
raise SanicException(
"Cannot use multiple bare custom models to define "
"multiple responses like openapi.definition(response=["
"MyModel1, MyModel2]). Instead, you should wrap them in a "
"dict or a Response object. See "
"https://sanic.dev/en/plugins/sanic-ext/openapi/decorators"
".html#response for more details."
)
for resp in resplist:
kwargs = {}
if isinstance(resp, definitions.Response):
kwargs = resp.fields
elif isinstance(resp, dict):
if "content" in resp:
kwargs = resp
else:
kwargs["content"] = resp
else:
kwargs["content"] = resp
if "status" not in kwargs:
kwargs["status"] = "default"
func = glbl["response"](**kwargs)(func)
if secured:
func = glbl["secured"](secured)(func)
return func
return inner
@@ -0,0 +1,452 @@
import json
import uuid
from dataclasses import MISSING, is_dataclass
from datetime import date, datetime, time
from enum import Enum
from inspect import getmembers, isclass, isfunction, ismethod
from typing import (
Any,
Optional,
Union,
get_args,
get_origin,
get_type_hints,
)
from sanic_routing.patterns import alpha, ext, nonemptystr, parse_date, slug
from sanic_ext.utils.typing import (
UnionType,
is_attrs,
is_generic,
is_msgspec,
is_pydantic,
)
try:
import attrs
NOTHING: Any = attrs.NOTHING
except ImportError:
NOTHING = object()
try:
import msgspec
from msgspec.inspect import Metadata as MsgspecMetadata
from msgspec.inspect import type_info as msgspec_type_info
MsgspecMetadata: Any = MsgspecMetadata
NODEFAULT: Any = msgspec.NODEFAULT
UNSET: Any = msgspec.UNSET
class MsgspecAdapter(msgspec.Struct):
name: str
default: Any
metadata: dict
except ImportError:
def msgspec_type_info(struct):
pass
class MsgspecAdapter:
pass
MsgspecMetadata = object()
NODEFAULT = object()
UNSET = object()
class Definition:
__nullable__: Optional[list[str]] = []
__ignore__: Optional[list[str]] = []
def __init__(self, **kwargs):
self._fields: dict[str, Any] = self.guard(kwargs)
@property
def fields(self):
return self._fields
def guard(self, fields):
return {
k: v
for k, v in fields.items()
if k in _properties(self).keys() or k.startswith("x-")
}
def serialize(self):
return {
k: self._value(v)
for k, v in _serialize(self.fields).items()
if (
k not in self.__ignore__
and (
v is not None
or (
isinstance(self.__nullable__, list)
and (not self.__nullable__ or k in self.__nullable__)
)
)
)
}
def __str__(self):
return json.dumps(self.serialize())
@staticmethod
def _value(value):
if isinstance(value, Enum):
return value.value
return value
class Schema(Definition):
title: str
description: str
type: str
format: str
nullable: bool
required: bool
default: None
example: None
oneOf: list[Definition]
anyOf: list[Definition]
allOf: list[Definition]
additionalProperties: dict[str, str]
multipleOf: int
maximum: int
exclusiveMaximum: bool
minimum: int
exclusiveMinimum: bool
maxLength: int
minLength: int
pattern: str
enum: Union[list[Any], Enum]
@staticmethod
def make(value, **kwargs):
_type = type(value)
origin = get_origin(value)
args = get_args(value)
if origin in (Union, UnionType):
if type(None) in args:
kwargs["nullable"] = True
filtered = [arg for arg in args if arg is not type(None)] # noqa
if len(filtered) == 1:
return Schema.make(filtered[0], **kwargs)
return Schema(
oneOf=[Schema.make(arg) for arg in filtered], **kwargs
)
for field in ("type", "format"):
kwargs.pop(field, None)
if isinstance(value, Schema):
return value
if value is bool:
return Boolean(**kwargs)
elif value is int:
return Integer(**kwargs)
elif value is float:
return Float(**kwargs)
elif value is str or value in (nonemptystr, ext, slug, alpha):
return String(**kwargs)
elif value is bytes:
return Byte(**kwargs)
elif value is bytearray:
return Binary(**kwargs)
elif value is date:
return Date(**kwargs)
elif value is time:
return Time(**kwargs)
elif value is datetime or value is parse_date:
return DateTime(**kwargs)
elif value is uuid.UUID:
return UUID(**kwargs)
elif value is Any:
return AnyValue(**kwargs)
if _type is bool:
return Boolean(default=value, **kwargs)
elif _type is int:
return Integer(default=value, **kwargs)
elif _type is float:
return Float(default=value, **kwargs)
elif _type is str:
return String(default=value, **kwargs)
elif _type is bytes:
return Byte(default=value, **kwargs)
elif _type is bytearray:
return Binary(default=value, **kwargs)
elif _type is date:
return Date(**kwargs)
elif _type is time:
return Time(**kwargs)
elif _type is datetime:
return DateTime(**kwargs)
elif _type is uuid.UUID:
return UUID(**kwargs)
elif _type is list:
if len(value) == 0:
schema = Schema(nullable=True)
elif len(value) == 1:
schema = Schema.make(value[0])
else:
schema = Schema(oneOf=[Schema.make(x) for x in value])
return Array(schema, **kwargs)
elif _type is dict:
return Object.make(value, **kwargs)
elif (
(is_generic(value) or is_generic(_type))
and origin is dict
and len(args) == 2
):
kwargs["additionalProperties"] = Schema.make(args[1])
return Object(**kwargs)
elif (is_generic(value) or is_generic(_type)) and origin is list:
kwargs.pop("items", None)
return Array(Schema.make(args[0]), **kwargs)
elif _type is type(Enum):
available = [item.value for item in value.__members__.values()]
available_types = list({type(item) for item in available})
schema_type = (
available_types[0] if len(available_types) == 1 else "string"
)
return Schema.make(
schema_type,
enum=[item.value for item in value.__members__.values()],
)
else:
return Object.make(value, **kwargs)
class Boolean(Schema):
def __init__(self, **kwargs):
super().__init__(type="boolean", **kwargs)
class Integer(Schema):
def __init__(self, **kwargs):
super().__init__(type="integer", format="int32", **kwargs)
class Long(Schema):
def __init__(self, **kwargs):
super().__init__(type="integer", format="int64", **kwargs)
class Float(Schema):
def __init__(self, **kwargs):
super().__init__(type="number", format="float", **kwargs)
class Double(Schema):
def __init__(self, **kwargs):
super().__init__(type="number", format="double", **kwargs)
class String(Schema):
def __init__(self, **kwargs):
super().__init__(type="string", **kwargs)
class Byte(Schema):
def __init__(self, **kwargs):
super().__init__(type="string", format="byte", **kwargs)
class Binary(Schema):
def __init__(self, **kwargs):
super().__init__(type="string", format="binary", **kwargs)
class Date(Schema):
def __init__(self, **kwargs):
super().__init__(type="string", format="date", **kwargs)
class Time(Schema):
def __init__(self, **kwargs):
super().__init__(type="string", format="time", **kwargs)
class DateTime(Schema):
def __init__(self, **kwargs):
super().__init__(type="string", format="date-time", **kwargs)
class Password(Schema):
def __init__(self, **kwargs):
super().__init__(type="string", format="password", **kwargs)
class Email(Schema):
def __init__(self, **kwargs):
super().__init__(type="string", format="email", **kwargs)
class UUID(Schema):
def __init__(self, **kwargs):
super().__init__(type="string", format="uuid", **kwargs)
class AnyValue(Schema):
@classmethod
def make(cls, value: Any, **kwargs):
return cls(
AnyValue={},
**kwargs,
)
class Object(Schema):
properties: dict[str, Schema]
maxProperties: int
minProperties: int
def __init__(
self, properties: Optional[dict[str, Schema]] = None, **kwargs
):
if properties:
kwargs["properties"] = properties
super().__init__(type="object", **kwargs)
@classmethod
def make(cls, value: Any, **kwargs):
extra: dict[str, Any] = {}
# Extract from field metadata if msgspec, pydantic, attrs, or dataclass
if isclass(value):
fields = ()
if is_pydantic(value):
try:
value = value.__pydantic_model__
except AttributeError:
...
extra = value.schema()["properties"]
elif is_attrs(value):
fields = value.__attrs_attrs__
elif is_dataclass(value):
fields = value.__dataclass_fields__.values()
elif is_msgspec(value):
# adapt to msgspec metadata layout -- annotated type --
# to match dataclass "metadata" attribute
fields = [
MsgspecAdapter(
name=f.name,
default=(
MISSING
if f.default in (UNSET, NODEFAULT)
else f.default
),
metadata=getattr(f.type, "extra", {}),
)
for f in msgspec_type_info(value).fields
]
if fields:
extra = {
field.name: {
"title": field.name.title(),
**(
{"default": field.default}
if field.default not in (MISSING, NOTHING)
else {}
),
**dict(field.metadata).get("openapi", {}),
}
for field in fields
}
return cls(
{
k: Schema.make(v, **extra.get(k, {}))
for k, v in _properties(value).items()
},
**kwargs,
)
class Array(Schema):
items: Any
maxItems: int
minItems: int
uniqueItems: bool
def __init__(self, items: Any, **kwargs):
super().__init__(type="array", items=Schema.make(items), **kwargs)
def _serialize(value) -> Any:
if isinstance(value, Definition):
return value.serialize()
if isinstance(value, type) and issubclass(value, Enum):
return [item.value for item in value.__members__.values()]
if isinstance(value, dict):
return {k: _serialize(v) for k, v in value.items()}
if isinstance(value, list):
return [_serialize(v) for v in value]
return value
def _properties(value: object) -> dict:
try:
fields = {
x: val
for x, v in getmembers(value, _is_property)
if (val := _extract(v)) and x in value.__dict__
}
except AttributeError:
fields = {}
cls = value if callable(value) else value.__class__
extra = value if isinstance(value, dict) else {}
try:
annotations = get_type_hints(cls)
except (NameError, TypeError):
if hasattr(value, "__annotations__"):
annotations = value.__annotations__
else:
annotations = {}
annotations.pop("return", None)
try:
output = {
k: v
for k, v in {**fields, **annotations, **extra}.items()
if not k.startswith("_")
and not (
isclass(v)
and isclass(cls)
and v.__qualname__.endswith(
f"{getattr(cls, '__name__', '<unknown>')}."
f"{getattr(v, '__name__', '<unknown>')}"
)
)
}
except TypeError:
return {}
return output
def _extract(item):
if isinstance(item, property):
hints = get_type_hints(item.fget)
return hints.get("return")
return item
def _is_property(item):
return not isfunction(item) and not ismethod(item)
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<title>__HTML_TITLE__</title>
<style>
body {
margin: 0;
padding: 0;
}
__HTML_CUSTOM_CSS__
</style>
</head>
<body>
<redoc spec-url="__URL_PREFIX__/openapi.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"></script>
</body>
</html>
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/__VERSION__/swagger-ui.min.css">
<title>__HTML_TITLE__</title>
<style>
body {
margin: 0;
padding: 0;
}
__HTML_CUSTOM_CSS__
</style>
</head>
<body>
<div id="openapi"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/__VERSION__/swagger-ui-bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/__VERSION__/swagger-ui-standalone-preset.min.js"></script>
<script>
window.onload = function () {
const ui = SwaggerUIBundle({
url: "__URL_PREFIX__/openapi.json",
dom_id: "#openapi",
configUrl: "__URL_PREFIX__/swagger-config",
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
})
}
</script>
</body>
</html>
@@ -0,0 +1,78 @@
from __future__ import annotations
from functools import wraps
from inspect import isawaitable
from typing import TYPE_CHECKING, Optional, Union
from jinja2 import Environment
from sanic.compat import Header
from sanic.request import Request
from sanic.response import HTTPResponse
from sanic_ext.extensions.templating.render import (
LazyResponse,
TemplateResponse,
)
if TYPE_CHECKING:
from sanic_ext import Config
class Templating:
def __init__(self, environment: Environment, config: Config) -> None:
self.environment = environment
self.config = config
def template(
self,
file_name: str,
status: int = 200,
headers: Optional[Union[Header, dict[str, str]]] = None,
content_type: str = "text/html; charset=utf-8",
**kwargs,
):
template = self.environment.get_template(file_name)
render = (
template.render_async
if self.config.TEMPLATING_ENABLE_ASYNC
else template.render
)
def decorator(f):
@wraps(f)
async def decorated_function(*args, **kwargs):
context = f(*args, **kwargs)
if isawaitable(context):
context = await context
if isinstance(context, HTTPResponse) and not isinstance(
context, TemplateResponse
):
return context
# TODO
# - Allow each of these to be a callable that is executed here
params = {
"status": status,
"content_type": content_type,
"headers": headers,
}
if isinstance(context, LazyResponse):
for attr in ("status", "headers", "content_type"):
value = getattr(context, attr, None)
if value:
params[attr] = value
context = context.context
context["request"] = Request.get_current()
content = render(**context)
if isawaitable(content):
content = await content
return HTTPResponse(content, **params)
return decorated_function
return decorator
@@ -0,0 +1,57 @@
from __future__ import annotations
import os
from collections import abc
from collections.abc import Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Union
from jinja2 import (
Environment,
FileSystemLoader,
__version__,
select_autoescape,
)
from sanic_ext.extensions.templating.engine import Templating
from ..base import Extension
if TYPE_CHECKING:
from sanic_ext import Extend
class TemplatingExtension(Extension):
name = "templating"
def startup(self, bootstrap: Extend) -> None:
self._add_template_paths_to_reloader(
self.config.TEMPLATING_PATH_TO_TEMPLATES
)
loader = FileSystemLoader(self.config.TEMPLATING_PATH_TO_TEMPLATES)
if not hasattr(bootstrap, "environment"):
bootstrap.environment = Environment(
loader=loader,
autoescape=select_autoescape(),
enable_async=self.config.TEMPLATING_ENABLE_ASYNC,
)
if not hasattr(bootstrap, "templating"):
bootstrap.templating = Templating(
environment=bootstrap.environment, config=self.config
)
bootstrap.templating.environment.globals["url_for"] = self.app.url_for
def label(self):
return f"jinja2=={__version__}"
def _add_template_paths_to_reloader(
self, path: Union[str, os.PathLike, Sequence[Union[str, os.PathLike]]]
) -> None:
if not isinstance(path, abc.Iterable) or isinstance(path, str):
path = [path]
for item in path:
self.app.state.reload_dirs.add(Path(item))
@@ -0,0 +1,105 @@
from __future__ import annotations
from inspect import isawaitable
from typing import TYPE_CHECKING, Any, Optional, Union
from sanic import Sanic
from sanic.compat import Header
from sanic.exceptions import SanicException
from sanic.request import Request
from sanic.response import HTTPResponse
from sanic_ext.exceptions import ExtensionNotFound
if TYPE_CHECKING:
from jinja2 import Environment
class TemplateResponse(HTTPResponse): ...
class LazyResponse(TemplateResponse):
__slots__ = (
"body",
"status",
"content_type",
"headers",
"_cookies",
"context",
)
def __init__(
self,
context: dict[str, Any],
status: int = 0,
headers: Optional[Union[Header, dict[str, str]]] = None,
content_type: Optional[str] = None,
):
super().__init__(
content_type=content_type, status=status, headers=headers
)
self.context = context
async def render(
template_name: str = "",
status: int = 200,
headers: Optional[dict[str, str]] = None,
content_type: str = "text/html; charset=utf-8",
app: Optional[Sanic] = None,
environment: Optional[Environment] = None,
context: Optional[dict[str, Any]] = None,
*,
template_source: str = "",
) -> TemplateResponse:
if app is None:
try:
app = Sanic.get_app()
except SanicException as e:
raise SanicException(
"Cannot render template beause locating the Sanic application "
"was ambiguous. Please return render(..., app=some_app)."
) from e
if template_name and template_source:
raise SanicException(
"You must provide template_name OR template_source, not both."
)
if environment is None:
try:
environment = app.ext.environment
except AttributeError:
raise ExtensionNotFound(
"The Templating extension does not appear to be enabled. "
"Perhaps jinja2 is not installed."
)
kwargs = context if context else {}
kwargs["request"] = Request.get_current()
if template_name or template_source:
template = (
environment.get_template(template_name)
if template_name
else environment.from_string(template_source)
)
render = (
template.render_async
if app.config.TEMPLATING_ENABLE_ASYNC
else template.render
)
content = render(**kwargs)
if isawaitable(content):
content = await content # type: ignore
return TemplateResponse( # type: ignore
content, status=status, headers=headers, content_type=content_type
)
else:
return LazyResponse(
kwargs, status=status, headers=headers, content_type=content_type
)