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,26 @@
from importlib.metadata import version
from sanic_ext.bootstrap import Extend
from sanic_ext.config import Config
from sanic_ext.extensions.base import Extension
from sanic_ext.extensions.http.cors import cors
from sanic_ext.extensions.openapi import openapi
from sanic_ext.extensions.templating.render import render
from sanic_ext.extras.request import CountedRequest
from sanic_ext.extras.serializer.decorator import serializer
from sanic_ext.extras.validation.decorator import validate
__version__ = version("sanic-ext")
__all__ = [
"Config",
"CountedRequest",
"Extend",
"Extension",
"cors",
"openapi",
"render",
"serializer",
"validate",
]
@@ -0,0 +1,224 @@
from __future__ import annotations
import os
from collections.abc import Mapping
from types import SimpleNamespace
from typing import Any, Callable, Optional, Union
from warnings import warn
from sanic import Sanic, __version__
from sanic.config import DEFAULT_CONFIG
from sanic.exceptions import SanicException
from sanic.helpers import Default, _default
from sanic.log import logger
from sanic_ext.config import Config, add_fallback_config
from sanic_ext.extensions.base import Extension
from sanic_ext.extensions.health.extension import HealthExtension
from sanic_ext.extensions.http.extension import HTTPExtension
from sanic_ext.extensions.injection.extension import InjectionExtension
from sanic_ext.extensions.injection.registry import (
ConstantRegistry,
InjectionRegistry,
)
from sanic_ext.extensions.logging.extension import LoggingExtension
from sanic_ext.extensions.openapi.builders import SpecificationBuilder
from sanic_ext.extensions.openapi.extension import OpenAPIExtension
from sanic_ext.utils.string import camel_to_snake
from sanic_ext.utils.version import get_version
try:
from jinja2 import Environment
from sanic_ext.extensions.templating.engine import Templating
from sanic_ext.extensions.templating.extension import TemplatingExtension
TEMPLATING_ENABLED = True
except (ImportError, ModuleNotFoundError):
TEMPLATING_ENABLED = False
MIN_SUPPORT = (21, 3, 2)
class Extend:
_pre_registry: list[Union[type[Extension], Extension]] = []
if TEMPLATING_ENABLED:
environment: Environment
templating: Templating
def __init__(
self,
app: Sanic,
*,
extensions: Optional[list[Union[type[Extension], Extension]]] = None,
built_in_extensions: bool = True,
config: Optional[Union[Config, dict[str, Any]]] = None,
**kwargs,
) -> None:
"""
Ingress for instantiating sanic-ext
:param app: Sanic application
:type app: Sanic
"""
if not isinstance(app, Sanic):
raise SanicException(
f"Cannot apply SanicExt to {app.__class__.__name__}"
)
sanic_version = get_version(__version__)
if MIN_SUPPORT > sanic_version:
min_version = ".".join(map(str, MIN_SUPPORT))
raise SanicException(
f"Sanic Extensions only works with Sanic v{min_version} "
f"and above. It looks like you are running {__version__}."
)
self._injection_registry: Optional[InjectionRegistry] = None
self._constant_registry: Optional[ConstantRegistry] = None
self._openapi: Optional[SpecificationBuilder] = None
self.app = app
self.extensions: list[Extension] = []
self.sanic_version = sanic_version
app._ext = self
app.ctx._dependencies = SimpleNamespace()
if not isinstance(config, Config):
config = Config.from_dict(config or {})
self.config = add_fallback_config(app, config, **kwargs)
extensions = extensions or []
if built_in_extensions:
extensions.extend(
[
InjectionExtension,
OpenAPIExtension,
HTTPExtension,
HealthExtension,
LoggingExtension,
]
)
if TEMPLATING_ENABLED:
extensions.append(TemplatingExtension)
extensions.extend(Extend._pre_registry)
started = set()
for ext in extensions:
if ext in started:
continue
extension = Extension.create(ext, app, self.config)
extension._startup(self)
self.extensions.append(extension)
started.add(ext)
def _display(self):
if "SANIC_WORKER_IDENTIFIER" in os.environ:
return
init_logs = ["Sanic Extensions:"]
for extension in self.extensions:
label = extension.render_label()
if extension.included():
init_logs.append(f" > {extension.name} {label}")
list(map(logger.info, init_logs))
def injection(
self,
type: type,
constructor: Optional[Callable[..., Any]] = None,
) -> None:
warn(
"The 'ext.injection' method has been deprecated and will be "
"removed in v22.6. Please use 'ext.add_dependency' instead.",
DeprecationWarning,
)
self.add_dependency(type=type, constructor=constructor)
def add_dependency(
self,
type: type,
constructor: Optional[Callable[..., Any]] = None,
request_arg: Optional[str] = None,
) -> None:
"""
Add a dependency for injection
:param type: The type of the dependency
:type type: Type
:param constructor: A callable that will return an instance to be
injected, when ``False`` it will call the type, defaults to None
:type constructor: Optional[Callable[..., Any]], optional
:param request_arg: Explicitly state which argument in the
constructor (if any) should be a ``Request`` object, when set to
``None`` the constructor will be introspected to check the
type annotations looking for a request object. You should really
only use this if you **MUST** use a lambda explression, it is
otherwise better to use a properly type annotated constructor,
defaults to None
:type request_arg: Optional[str], optional
:raises SanicException: _description_
"""
if not self._injection_registry:
raise SanicException("Injection extension not enabled")
self._injection_registry.register(
type, constructor, request_arg=request_arg
)
def add_constant(self, name: str, value: Any, overwrite: bool = False):
if not self._constant_registry:
raise ValueError("Cannot add constant. No registry created.")
self._constant_registry.register(name, value, overwrite)
def load_constants(
self,
constants: Optional[Mapping[str, Any]] = None,
overwrite: Union[Default, bool] = _default,
):
if not constants:
constants = {
k: v
for k, v in self.app.config.items()
if k.isupper()
and k not in DEFAULT_CONFIG
and k not in self.config
and not k.startswith("_")
}
if isinstance(overwrite, Default):
overwrite = True
if isinstance(overwrite, Default):
overwrite = False
for name, value in constants.items():
self.add_constant(name, value, overwrite)
def dependency(self, obj: Any, name: Optional[str] = None) -> None:
if not name:
name = camel_to_snake(obj.__class__.__name__)
setattr(self.app.ctx._dependencies, name, obj)
def getter(*_):
return obj
self.add_dependency(obj.__class__, getter)
@property
def openapi(self) -> SpecificationBuilder:
if not self._openapi:
self._openapi = SpecificationBuilder()
return self._openapi
def template(self, template_name: str, **kwargs):
return self.templating.template(template_name, **kwargs)
@classmethod
def register(cls, extension: Union[type[Extension], Extension]) -> None:
cls._pre_registry.append(extension)
@classmethod
def reset(cls) -> None:
cls._pre_registry.clear()
@@ -0,0 +1,181 @@
from __future__ import annotations
import os
from collections.abc import Sequence
from typing import Any, Optional, Union
from sanic import Sanic
from sanic.config import Config as SanicConfig
from sanic.exceptions import SanicException
from sanic.signals import Event
PRIORITY = -1_000
class Config(SanicConfig):
def __init__(
self,
cors: bool = True,
cors_allow_headers: str = "*",
cors_always_send: bool = True,
cors_automatic_options: bool = True,
cors_expose_headers: str = "",
cors_max_age: int = 5,
cors_methods: str = "",
cors_origins: Union[str, list[str]] = "",
cors_send_wildcard: bool = False,
cors_supports_credentials: bool = False,
cors_vary_header: bool = True,
health: bool = False,
health_endpoint: bool = False,
health_max_misses: int = 3,
health_missed_threshhold: int = 10,
health_monitor: bool = True,
health_report_interval: int = 5,
health_uri_to_info: str = "",
health_url_prefix: str = "/__health__",
http_all_methods: bool = True,
http_auto_head: bool = True,
http_auto_options: bool = True,
http_auto_trace: bool = False,
injection_signal: Union[str, Event] = Event.HTTP_ROUTING_AFTER,
injection_priority: int = PRIORITY,
injection_load_custom_constants: bool = False,
logging: bool = False,
logging_queue_max_size: int = 4096,
loggers: list[str] = [
"sanic.access",
"sanic.error",
"sanic.root",
"sanic.server",
"sanic.websockets",
],
oas: bool = True,
oas_autodoc: bool = True,
oas_custom_file: Optional[os.PathLike] = None,
oas_ignore_head: bool = True,
oas_ignore_options: bool = True,
oas_path_to_redoc_html: Optional[str] = None,
oas_path_to_swagger_html: Optional[str] = None,
oas_ui_default: Optional[str] = "redoc",
oas_ui_redoc: bool = True,
oas_ui_redoc_html_title: str = "ReDoc",
oas_ui_redoc_custom_css: str = "",
oas_ui_swagger: bool = True,
oas_ui_swagger_html_title: str = "OpenAPI Swagger",
oas_ui_swagger_custom_css: str = "",
oas_ui_swagger_version: str = "4.10.3",
oas_ui_swagger_oauth2_redirect: str = "/oauth2-redirect.html",
oas_uri_to_config: str = "/swagger-config",
oas_uri_to_json: str = "/openapi.json",
oas_uri_to_redoc: str = "/redoc",
oas_uri_to_swagger: str = "/swagger",
oas_url_prefix: str = "/docs",
swagger_ui_configuration: Optional[dict[str, Any]] = None,
templating_path_to_templates: Union[
str, os.PathLike, Sequence[Union[str, os.PathLike]]
] = "templates",
templating_enable_async: bool = True,
trace_excluded_headers: Sequence[str] = ("authorization", "cookie"),
**kwargs,
):
self.CORS = cors
self.CORS_ALLOW_HEADERS = cors_allow_headers
self.CORS_ALWAYS_SEND = cors_always_send
self.CORS_AUTOMATIC_OPTIONS = cors_automatic_options
self.CORS_EXPOSE_HEADERS = cors_expose_headers
self.CORS_MAX_AGE = cors_max_age
self.CORS_METHODS = cors_methods
self.CORS_ORIGINS = cors_origins
self.CORS_SEND_WILDCARD = cors_send_wildcard
self.CORS_SUPPORTS_CREDENTIALS = cors_supports_credentials
self.CORS_VARY_HEADER = cors_vary_header
self.HEALTH = health
self.HEALTH_ENDPOINT = health_endpoint
self.HEALTH_MAX_MISSES = health_max_misses
self.HEALTH_MISSED_THRESHHOLD = health_missed_threshhold
self.HEALTH_MONITOR = health_monitor
self.HEALTH_REPORT_INTERVAL = health_report_interval
self.HEALTH_URI_TO_INFO = health_uri_to_info
self.HEALTH_URL_PREFIX = health_url_prefix
self.HTTP_ALL_METHODS = http_all_methods
self.HTTP_AUTO_HEAD = http_auto_head
self.HTTP_AUTO_OPTIONS = http_auto_options
self.HTTP_AUTO_TRACE = http_auto_trace
self.INJECTION_SIGNAL = injection_signal
self.INJECTION_PRIORITY = injection_priority
self.INJECTION_LOAD_CUSTOM_CONSTANTS = injection_load_custom_constants
self.LOGGING = logging
self.LOGGING_QUEUE_MAX_SIZE = logging_queue_max_size
self.LOGGERS = loggers
self.OAS = oas
self.OAS_AUTODOC = oas_autodoc
self.OAS_CUSTOM_FILE = oas_custom_file
self.OAS_IGNORE_HEAD = oas_ignore_head
self.OAS_IGNORE_OPTIONS = oas_ignore_options
self.OAS_PATH_TO_REDOC_HTML = oas_path_to_redoc_html
self.OAS_PATH_TO_SWAGGER_HTML = oas_path_to_swagger_html
self.OAS_UI_DEFAULT = oas_ui_default
self.OAS_UI_REDOC = oas_ui_redoc
self.OAS_UI_REDOC_HTML_TITLE = oas_ui_redoc_html_title
self.OAS_UI_REDOC_CUSTOM_CSS = oas_ui_redoc_custom_css
self.OAS_UI_SWAGGER = oas_ui_swagger
self.OAS_UI_SWAGGER_HTML_TITLE = oas_ui_swagger_html_title
self.OAS_UI_SWAGGER_CUSTOM_CSS = oas_ui_swagger_custom_css
self.OAS_UI_SWAGGER_VERSION = oas_ui_swagger_version
self.OAS_UI_SWAGGER_OAUTH2_REDIRECT = oas_ui_swagger_oauth2_redirect
self.OAS_URI_TO_CONFIG = oas_uri_to_config
self.OAS_URI_TO_JSON = oas_uri_to_json
self.OAS_URI_TO_REDOC = oas_uri_to_redoc
self.OAS_URI_TO_SWAGGER = oas_uri_to_swagger
self.OAS_URL_PREFIX = oas_url_prefix
self.SWAGGER_UI_CONFIGURATION = swagger_ui_configuration or {
"apisSorter": "alpha",
"operationsSorter": "alpha",
"docExpansion": "full",
}
self.TEMPLATING_PATH_TO_TEMPLATES = templating_path_to_templates
self.TEMPLATING_ENABLE_ASYNC = templating_enable_async
self.TRACE_EXCLUDED_HEADERS = trace_excluded_headers
if isinstance(self.TRACE_EXCLUDED_HEADERS, str):
self.TRACE_EXCLUDED_HEADERS = tuple(
self.TRACE_EXCLUDED_HEADERS.split(",")
)
if isinstance(self.INJECTION_SIGNAL, str):
self.INJECTION_SIGNAL = Event(self.INJECTION_SIGNAL)
valid_signals = ("http.handler.before", "http.routing.after")
if self.INJECTION_SIGNAL.value not in valid_signals:
raise SanicException(
f"Injection signal may only be one of {valid_signals}"
)
self.load({key.upper(): value for key, value in kwargs.items()})
@classmethod
def from_dict(cls, mapping) -> Config:
return cls(**mapping)
def add_fallback_config(
app: Sanic, config: Optional[Config] = None, **kwargs
) -> Config:
if config is None:
config = Config(**kwargs)
app.config.update(
{key: value for key, value in config.items() if key not in app.config}
)
config.update(
{
key: value
for key, value in app.config.items()
if key in config and value != config.get(key)
}
)
return config
@@ -0,0 +1,11 @@
from sanic.exceptions import SanicException
class ValidationError(SanicException):
status_code = 400
class InitError(SanicException): ...
class ExtensionNotFound(SanicException): ...
@@ -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
)
@@ -0,0 +1,46 @@
from itertools import count
from sanic import Request, Sanic
from sanic.compat import Header
from sanic.models.protocol_types import TransportProtocol
class CountedRequest(Request):
__slots__ = ()
_counter = count()
count = next(_counter)
def __init__(
self,
url_bytes: bytes,
headers: Header,
version: str,
method: str,
transport: TransportProtocol,
app: Sanic,
head: bytes = b"",
stream_id: int = 0,
):
super().__init__(
url_bytes,
headers,
version,
method,
transport,
app,
head,
stream_id,
)
self.__class__._increment()
if hasattr(self.app, "multiplexer"):
self.app.multiplexer.state["request_count"] = self.__class__.count
@classmethod
def _increment(cls):
cls.count = next(cls._counter)
@classmethod
def reset_count(cls):
cls._counter = count()
cls.count = next(cls._counter)
@@ -0,0 +1,42 @@
from functools import wraps
from inspect import isawaitable, signature
from typing import Callable, TypeVar
from sanic import response
T = TypeVar("T")
def serializer(func, *, status: int = 200) -> Callable[[T], T]:
sig = signature(func)
simple = len(sig.parameters) == 2 or (
func
in (
response.HTTPResponse,
response.file_stream,
response.file,
response.html,
response.json,
response.raw,
response.redirect,
response.text,
)
)
def decorator(f):
@wraps(f)
async def decorated_function(*args, **kwargs):
retval = f(*args, **kwargs)
if isawaitable(retval):
retval = await retval
if simple:
return func(retval, status=status)
else:
kwargs["status"] = status
return func(retval, *args, **kwargs)
return decorated_function
return decorator
@@ -0,0 +1,283 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import _HAS_DEFAULT_FACTORY # type: ignore
from typing import (
Any,
Literal,
NamedTuple,
Optional,
Union,
get_args,
get_origin,
)
from sanic_ext.utils.typing import (
UnionType,
is_generic,
is_msgspec,
is_optional,
)
MISSING: tuple[Any, ...] = (_HAS_DEFAULT_FACTORY,)
try:
import attrs # noqa
NOTHING = attrs.NOTHING
ATTRS = True
MISSING = (
_HAS_DEFAULT_FACTORY,
NOTHING,
)
except ImportError:
ATTRS = False
try:
import msgspec
MSGSPEC = True
except ImportError:
MSGSPEC = False
class Hint(NamedTuple):
hint: Any
model: bool
literal: bool
typed: bool
nullable: bool
origin: Optional[Any]
allowed: tuple[Hint, ...] # type: ignore
allow_missing: bool
def validate(
self, value, schema, allow_multiple=False, allow_coerce=False
):
if not self.typed:
if self.model:
return check_data(
self.hint,
value,
schema,
allow_multiple=allow_multiple,
allow_coerce=allow_coerce,
)
if (
allow_multiple
and isinstance(value, list)
and self.coerce_type is not list
and len(value) == 1
):
value = value[0]
try:
_check_types(value, self.literal, self.hint)
except ValueError as e:
if allow_coerce:
value = self.coerce(value)
_check_types(value, self.literal, self.hint)
else:
raise e
else:
value = _check_nullability(
value,
self.nullable,
self.allowed,
schema,
allow_multiple,
allow_coerce,
)
if not self.nullable:
if self.origin in (Union, Literal, UnionType):
value = _check_inclusion(
value,
self.allowed,
schema,
allow_multiple,
allow_coerce,
)
elif self.origin is list:
value = _check_list(
value,
self.allowed,
self.hint,
schema,
allow_multiple,
allow_coerce,
)
elif self.origin is dict:
value = _check_dict(
value,
self.allowed,
self.hint,
schema,
allow_multiple,
allow_coerce,
)
if allow_coerce:
value = self.coerce(value)
return value
def coerce(self, value):
if is_generic(self.coerce_type):
args = get_args(self.coerce_type)
if get_origin(self.coerce_type) == Literal or (
all(get_origin(arg) == Literal for arg in args)
):
return value
if type(None) in args and value is None:
return None
coerce_types = [arg for arg in args if not isinstance(None, arg)]
else:
coerce_types = [self.coerce_type]
for coerce_type in coerce_types:
try:
if isinstance(value, list):
value = [coerce_type(item) for item in value]
elif value is None and self.nullable:
value = None
else:
value = coerce_type(value)
except (ValueError, TypeError):
...
else:
return value
return value
@property
def coerce_type(self):
coerce_type = self.hint
if is_optional(coerce_type):
coerce_type = get_args(self.hint)[0]
return coerce_type
def check_data(model, data, schema, allow_multiple=False, allow_coerce=False):
if not isinstance(data, dict):
raise TypeError(f"Value '{data}' is not a dict")
sig = schema[model.__name__]["sig"]
hints = schema[model.__name__]["hints"]
bound = sig.bind(**data)
bound.apply_defaults()
params = dict(zip(sig.parameters, bound.args))
params.update(bound.kwargs)
hydration_values = {}
try:
for key, value in params.items():
hint = hints.get(key, Any)
try:
hydration_values[key] = hint.validate(
value,
schema,
allow_multiple=allow_multiple,
allow_coerce=allow_coerce,
)
except ValueError:
if not hint.allow_missing or value not in MISSING:
raise
except ValueError as e:
raise TypeError(e)
if MSGSPEC and is_msgspec(model):
try:
return msgspec.convert(hydration_values, model, str_keys=True)
except AttributeError:
return msgspec.from_builtins(
hydration_values, model, str_values=True, str_keys=True
)
except msgspec.ValidationError as e:
raise TypeError(e)
else:
return model(**hydration_values)
def _check_types(value, literal, expected):
if literal:
if expected is Any:
return
elif value != expected:
raise ValueError(f"Value '{value}' must be {expected}")
else:
if MSGSPEC and is_msgspec(expected) and isinstance(value, Mapping):
try:
expected(**value)
except (TypeError, msgspec.ValidationError):
raise ValueError(f"Value '{value}' is not of type {expected}")
elif not isinstance(value, expected):
raise ValueError(f"Value '{value}' is not of type {expected}")
def _check_nullability(
value, nullable, allowed, schema, allow_multiple, allow_coerce
):
if not nullable and value is None:
raise ValueError("Value cannot be None")
if nullable and value is not None:
exc = None
for hint in allowed:
try:
value = hint.validate(
value, schema, allow_multiple, allow_coerce
)
except ValueError as e:
exc = e
else:
break
else:
if exc:
if len(allowed) == 1:
raise exc
else:
options = ", ".join(
[str(option.hint) for option in allowed]
)
raise ValueError(
f"Value '{value}' must be one of {options}, or None"
)
return value
def _check_inclusion(value, allowed, schema, allow_multiple, allow_coerce):
for option in allowed:
try:
return option.validate(value, schema, allow_multiple, allow_coerce)
except (ValueError, TypeError):
...
options = ", ".join([str(option.hint) for option in allowed])
raise ValueError(f"Value '{value}' must be one of {options}")
def _check_list(value, allowed, hint, schema, allow_multiple, allow_coerce):
if isinstance(value, list):
try:
return [
_check_inclusion(
item, allowed, schema, allow_multiple, allow_coerce
)
for item in value
]
except (ValueError, TypeError):
...
raise ValueError(f"Value '{value}' must be a {hint}")
def _check_dict(value, allowed, hint, schema, allow_multiple, allow_coerce):
if isinstance(value, dict):
try:
return {
key: _check_inclusion(
item, allowed, schema, allow_multiple, allow_coerce
)
for key, item in value.items()
}
except (ValueError, TypeError):
...
raise ValueError(f"Value '{value}' must be a {hint}")
@@ -0,0 +1,17 @@
from typing import Any, get_origin, get_type_hints
def clean_data(model: type[object], data: dict[str, Any]) -> dict[str, Any]:
hints = get_type_hints(model)
return {key: _coerce(hints[key], value) for key, value in data.items()}
def _coerce(param_type, value: Any) -> Any:
if (
get_origin(param_type) is not list
and isinstance(value, list)
and len(value) == 1
):
value = value[0]
return value
@@ -0,0 +1,80 @@
from functools import wraps
from inspect import isawaitable
from typing import Callable, Optional, TypeVar, Union
from sanic import Request
from sanic_ext.exceptions import InitError
from sanic_ext.utils.extraction import extract_request
from .setup import do_validation, generate_schema
T = TypeVar("T")
def validate(
json: Optional[Union[Callable[[Request], bool], type[object]]] = None,
form: Optional[Union[Callable[[Request], bool], type[object]]] = None,
query: Optional[Union[Callable[[Request], bool], type[object]]] = None,
body_argument: str = "body",
query_argument: str = "query",
) -> Callable[[T], T]:
schemas = {
key: generate_schema(param)
for key, param in (
("json", json),
("form", form),
("query", query),
)
}
if json and form:
raise InitError("Cannot define both a form and json route validator")
def decorator(f):
@wraps(f)
async def decorated_function(*args, **kwargs):
request = extract_request(*args)
if schemas["json"]:
await do_validation(
model=json,
data=request.json,
schema=schemas["json"],
request=request,
kwargs=kwargs,
body_argument=body_argument,
allow_multiple=False,
allow_coerce=False,
)
elif schemas["form"]:
await do_validation(
model=form,
data=request.form,
schema=schemas["form"],
request=request,
kwargs=kwargs,
body_argument=body_argument,
allow_multiple=True,
allow_coerce=True,
)
if schemas["query"]:
await do_validation(
model=query,
data=request.args,
schema=schemas["query"],
request=request,
kwargs=kwargs,
body_argument=query_argument,
allow_multiple=True,
allow_coerce=True,
)
retval = f(*args, **kwargs)
if isawaitable(retval):
retval = await retval
return retval
return decorated_function
return decorator
@@ -0,0 +1,133 @@
import types
from dataclasses import MISSING, Field, is_dataclass
from inspect import isclass, signature
from typing import (
Any,
Literal,
Optional,
Union,
get_args,
get_origin,
get_type_hints,
)
from sanic_ext.utils.typing import is_attrs, is_generic, is_msgspec
from .check import Hint
try:
UnionType = types.UnionType # type: ignore
except AttributeError:
UnionType = type("UnionType", (), {})
try:
from attr import NOTHING, Attribute
except ModuleNotFoundError:
NOTHING = object() # type: ignore
Attribute = type("Attribute", (), {}) # type: ignore
try:
from msgspec.inspect import type_info as msgspec_type_info
except ModuleNotFoundError:
def msgspec_type_info(val):
pass
def make_schema(agg, item):
if type(item) in (bool, str, int, float):
return agg
if is_generic(item) and (args := get_args(item)):
for arg in args:
make_schema(agg, arg)
elif item.__name__ not in agg and (
is_dataclass(item) or is_attrs(item) or is_msgspec(item)
):
if is_dataclass(item):
fields = item.__dataclass_fields__
elif is_msgspec(item):
fields = {f.name: f.type for f in msgspec_type_info(item).fields}
else:
fields = {attr.name: attr for attr in item.__attrs_attrs__}
sig = signature(item)
hints = parse_hints(get_type_hints(item), fields)
agg[item.__name__] = {
"sig": sig,
"hints": hints,
}
for hint in hints.values():
make_schema(agg, hint.hint)
return agg
def parse_hints(
hints, fields: dict[str, Union[Field, Attribute]]
) -> dict[str, Hint]:
output: dict[str, Hint] = {
name: parse_hint(hint, fields.get(name))
for name, hint in hints.items()
}
return output
def parse_hint(hint, field: Optional[Union[Field, Attribute]] = None):
origin = None
literal = not isclass(hint)
nullable = False
typed = False
model = False
allowed: tuple[Any, ...] = tuple()
allow_missing = False
if field and (
(
isinstance(field, Field) and field.default_factory is not MISSING # type: ignore
)
or (isinstance(field, Attribute) and field.default is not NOTHING)
):
allow_missing = True
if is_dataclass(hint) or is_attrs(hint):
model = True
elif is_generic(hint):
typed = True
literal = False
origin = get_origin(hint)
args = get_args(hint)
nullable = origin in (Union, UnionType) and type(None) in args
if nullable:
allowed = tuple(
[
arg
for arg in args
if is_generic(arg) or not isinstance(None, arg)
]
)
elif origin is dict:
allowed = (args[1],)
elif (
origin is list
or origin is Literal
or origin is Union
or origin is UnionType
):
allowed = args
return Hint(
hint,
model,
literal,
typed,
nullable,
origin,
tuple([parse_hint(item, None) for item in allowed]),
allow_missing,
)
@@ -0,0 +1,69 @@
from functools import partial
from inspect import isawaitable, isclass
from sanic.log import logger
from sanic_ext.exceptions import ValidationError
from sanic_ext.utils.typing import is_msgspec, is_pydantic
from .schema import make_schema
from .validators import (
_msgspec_validate_instance,
_validate_annotations,
_validate_instance,
validate_body,
)
async def do_validation(
*,
model,
data,
schema,
request,
kwargs,
body_argument,
allow_multiple,
allow_coerce,
):
try:
logger.debug(f"Validating {request.path} using {model}")
if model is not None:
if isclass(model):
validator = _get_validator(
model, schema, allow_multiple, allow_coerce
)
validation = validate_body(validator, model, data)
kwargs[body_argument] = validation
else:
validation = model(
request=request, data=data, handler_kwargs=kwargs
)
if isawaitable(validation):
await validation
except TypeError as e:
raise ValidationError(e)
def generate_schema(param):
try:
if param is None or is_msgspec(param) or is_pydantic(param):
return param
except TypeError:
...
return make_schema({}, param) if isclass(param) else param
def _get_validator(model, schema, allow_multiple, allow_coerce):
if is_msgspec(model):
return partial(_msgspec_validate_instance, allow_coerce=allow_coerce)
elif is_pydantic(model):
return partial(_validate_instance, allow_coerce=allow_coerce)
return partial(
_validate_annotations,
schema=schema,
allow_multiple=allow_multiple,
allow_coerce=allow_coerce,
)
@@ -0,0 +1,52 @@
from typing import Any, Callable
from sanic_ext.exceptions import ValidationError
from .check import check_data
from .clean import clean_data
try:
from pydantic import ValidationError as PydanticValidationError
VALIDATION_ERROR: tuple[type[Exception], ...] = (
TypeError,
PydanticValidationError,
)
except ImportError:
VALIDATION_ERROR = (TypeError,)
def validate_body(
validator: Callable[[type[Any], dict[str, Any]], Any],
model: type[Any],
body: dict[str, Any],
) -> Any:
try:
return validator(model, body)
except VALIDATION_ERROR as e:
raise ValidationError(
f"Invalid request body: {model.__name__}. Error: {e}",
extra={"exception": str(e)},
) from None
def _msgspec_validate_instance(model, body, allow_coerce):
import msgspec
try:
data = clean_data(model, body) if allow_coerce else body
return msgspec.convert(data, model)
except msgspec.ValidationError as e:
# Convert msgspec.ValidationError into TypeError for consistent
# behaviour with _validate_instance
raise TypeError(str(e))
def _validate_instance(model, body, allow_coerce):
data = clean_data(model, body) if allow_coerce else body
return model(**data)
def _validate_annotations(model, body, schema, allow_multiple, allow_coerce):
return check_data(model, body, schema, allow_multiple, allow_coerce)
@@ -0,0 +1,13 @@
from sanic import Request
from sanic.exceptions import SanicException
def extract_request(*args) -> Request:
request: Request
if args and isinstance(args[0], Request):
request = args[0]
elif len(args) > 1:
request = args[1]
else:
raise SanicException("Request could not be found")
return request
@@ -0,0 +1,124 @@
import re
def clean_route_name(name: str) -> str:
parts = name.split(".", 1)
name = parts[-1]
for target in ("_", ".", " "):
name = name.replace(target, " ")
return name.title()
def get_uri_filter(app):
"""
Return a filter function that takes a URI and returns whether it should
be filter out from the swagger documentation or not.
Arguments:
app: The application to take `config.API_URI_FILTER` from. Possible
values for this config option are: `slash` (to keep URIs that
end with a `/`), `all` (to keep all URIs). All other values
default to keep all URIs that don't end with a `/`.
Returns:
`True` if the URI should be *filtered out* from the swagger
documentation, and `False` if it should be kept in the documentation.
"""
choice = getattr(app.config, "API_URI_FILTER", None)
if choice == "slash":
# Keep URIs that end with a /.
return lambda uri: not uri.endswith("/")
if choice == "all":
# Keep all URIs.
return lambda uri: False
# Keep URIs that don't end with a /, (special case: "/").
return lambda uri: len(uri) > 1 and uri.endswith("/")
def remove_nulls(dictionary, deep=True):
"""
Removes all null values from a dictionary.
"""
return {
k: remove_nulls(v, deep) if deep and isinstance(v, dict) else v
for k, v in dictionary.items()
if v is not None
}
def remove_nulls_from_kwargs(**kwargs):
return remove_nulls(kwargs, deep=False)
def get_blueprinted_routes(app):
for blueprint in app.blueprints.values():
if not hasattr(blueprint, "routes"):
continue
for route in blueprint.routes:
if hasattr(route.handler, "view_class"):
# before sanic 21.3, route.handler could be a number of
# different things, so have to type check
for http_method in route.methods:
_handler = getattr(
route.handler.view_class, http_method.lower(), None
)
if _handler:
yield (blueprint.name, _handler)
else:
yield (blueprint.name, route.handler)
def get_all_routes(app, skip_prefix):
uri_filter = get_uri_filter(app)
for group in app.router.groups.values():
uri = f"/{group.path}"
# prior to sanic 21.3 routes came in both forms
# (e.g. /test and /test/ )
# after sanic 21.3 routes come in one form,
# with an attribute "strict",
# so we simulate that ourselves:
uris = [uri]
if not group.strict and len(uri) > 1:
alt = uri[:-1] if uri.endswith("/") else f"{uri}/"
uris.append(alt)
for uri in uris:
if uri_filter(uri):
continue
if skip_prefix and group.raw_path.startswith(
skip_prefix.lstrip("/")
):
continue
for parameter in group.params.values():
uri = re.sub(
f"<{parameter.name}.*?>",
f"{{{parameter.name}}}",
uri,
)
for route in group:
if getattr(route.extra, "static", False):
continue
method_handlers = [
(method, route.handler) for method in route.methods
]
_, name = route.name.split(".", 1)
yield (
uri,
name,
route.params.values(),
method_handlers,
route.requirements.get("host"),
)
@@ -0,0 +1,13 @@
import re
CAMEL_TO_SNAKE_PATTERNS = (
re.compile(r"(.)([A-Z][a-z]+)"),
re.compile(r"([a-z0-9])([A-Z])"),
)
def camel_to_snake(name: str) -> str:
for pattern in CAMEL_TO_SNAKE_PATTERNS:
name = pattern.sub(r"\1_\2", name)
return name.lower()
@@ -0,0 +1,79 @@
import types
import typing
from inspect import isclass
try:
UnionType = types.UnionType # type: ignore
except AttributeError:
UnionType = type("UnionType", (), {}) # type: ignore
try:
from pydantic import BaseModel
PYDANTIC = True
except ImportError:
PYDANTIC = False
try:
import attrs # noqa
ATTRS = True
except ImportError:
ATTRS = False
try:
from msgspec import Struct
MSGSPEC = True
except ImportError:
MSGSPEC = False
def is_generic(item):
return (
isinstance(item, typing._GenericAlias)
or isinstance(item, UnionType)
or hasattr(item, "__origin__")
)
def is_optional(item):
if is_generic(item):
args = typing.get_args(item)
return len(args) == 2 and type(None) in args
return False
def is_pydantic(model):
return PYDANTIC and (
issubclass(model, BaseModel) or hasattr(model, "__pydantic_model__")
)
def is_attrs(model):
return ATTRS and (hasattr(model, "__attrs_attrs__"))
def is_msgspec(model):
return MSGSPEC and issubclass(model, Struct)
def flat_values(
item: typing.Union[dict[str, typing.Any], typing.Iterable[typing.Any]],
) -> set[typing.Any]:
values = set()
if isinstance(item, dict):
item = item.values()
for value in item:
if isinstance(value, dict) or isinstance(value, list):
values.update(flat_values(value))
else:
values.add(value)
return values
def contains_annotations(d: dict[str, typing.Any]) -> bool:
values = flat_values(d)
return any(isclass(q) or is_generic(q) for q in values)
@@ -0,0 +1,44 @@
import re
# Expression from https://github.com/pypa/packaging
VERSION_PATTERN = r"""
v?
(?:
(?:(?P<epoch>[0-9]+)!)? # epoch
(?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
(?P<pre> # pre-release
[-_\.]?
(?P<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
[-_\.]?
(?P<pre_n>[0-9]+)?
)?
(?P<post> # post release
(?:-(?P<post_n1>[0-9]+))
|
(?:
[-_\.]?
(?P<post_l>post|rev|r)
[-_\.]?
(?P<post_n2>[0-9]+)?
)
)?
(?P<dev> # dev release
[-_\.]?
(?P<dev_l>dev)
[-_\.]?
(?P<dev_n>[0-9]+)?
)?
)
(?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
"""
PATTERN = re.compile(
r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE
)
def get_version(version) -> tuple[int, ...]:
match = PATTERN.search(version)
if not match:
raise ValueError(f"Invalid version: {version}")
return tuple(map(int, match.group("release").split(".")))