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>
284 lines
8.2 KiB
Python
284 lines
8.2 KiB
Python
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}")
|