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,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)
|
||||
Reference in New Issue
Block a user