chore: rename project coven → hack-house ⛧

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-05-30 13:29:14 -07:00
parent 82a04f3e12
commit bb1d662ee1
2730 changed files with 712933 additions and 46 deletions
@@ -0,0 +1,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)