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:
@@ -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(".")))
|
||||
Reference in New Issue
Block a user