581 lines
19 KiB
Python

"""Lightweight type validation system replacing Pydantic.
Provides Schema base class, dynamic schema creation, and type validation
for config values against function signatures.
"""
import inspect
import sys
from typing import Any, Optional, get_type_hints
from .typechecker import Ctx
from .typechecker import check_type as _tc2_check_type
# Optional pydantic imports — confection doesn't depend on pydantic,
# but if it's installed we can detect and convert BaseModel schemas.
# Skip pydantic.v1 on Python 3.14+ where it is unsupported.
if sys.version_info >= (3, 14):
_PydanticV1BaseModel = None # type: ignore[assignment,misc]
_PydanticV1ValidationError = None # type: ignore[assignment,misc]
else:
try:
from pydantic.v1 import (
BaseModel as _PydanticV1BaseModel, # pyright: ignore[reportMissingImports]
)
from pydantic.v1 import (
ValidationError as _PydanticV1ValidationError, # pyright: ignore[reportMissingImports]
)
except (ImportError, ModuleNotFoundError): # pragma: no cover
_PydanticV1BaseModel = None # type: ignore[assignment,misc]
_PydanticV1ValidationError = None # type: ignore[assignment,misc]
try:
from pydantic import (
BaseModel as _PydanticV2BaseModel, # pyright: ignore[reportMissingImports]
)
from pydantic import (
ValidationError as _PydanticV2ValidationError, # pyright: ignore[reportMissingImports]
)
except (ImportError, ModuleNotFoundError): # pragma: no cover
_PydanticV2BaseModel = None # type: ignore[assignment,misc]
_PydanticV2ValidationError = None # type: ignore[assignment,misc]
# === Constrained Types ===
class StrictBool:
"""Only accepts actual bool values (not int 0/1)."""
pass
class PositiveInt:
"""Only accepts positive integers (> 0, not bool)."""
pass
class StrictFloat:
"""Only accepts actual float values (not int)."""
pass
# === Field Info ===
class FieldInfo:
"""Information about a schema field."""
__slots__ = ("default", "alias", "annotation")
def __init__(self, default=..., *, alias=None):
self.default = default
self.alias = alias
self.annotation: Any = None
def is_required(self):
return self.default is ...
def Field(default=..., *, alias=None):
"""Create a field definition."""
return FieldInfo(default=default, alias=alias)
# === Validation Error ===
class ValidationError(Exception):
"""Raised when schema validation fails."""
def __init__(self, error_list):
self._errors = error_list
msgs = "; ".join(e.get("msg", "") for e in error_list)
super().__init__(msgs)
def errors(self):
return self._errors
# === Schema ===
class _ValidatedResult:
"""Attribute-accessible result from model_validate."""
def __init__(self, data):
self.__dict__.update(data)
class Schema:
"""Base class for config validation schemas. Replaces pydantic.BaseModel."""
model_config: dict = {"extra": "allow", "arbitrary_types_allowed": True}
model_fields: dict = {}
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
fields = {}
all_hints = {}
for base in reversed(cls.__mro__):
base_annotations = getattr(base, "__annotations__", {})
all_hints.update(base_annotations)
for name, annotation in all_hints.items():
if name in ("model_config", "model_fields") or name.startswith("_"):
continue
default = ...
alias = None
for klass in cls.__mro__:
if name in klass.__dict__:
val = klass.__dict__[name]
if isinstance(val, FieldInfo):
default = val.default
alias = val.alias
elif not isinstance(
val, (type, classmethod, staticmethod, property)
):
if not callable(val):
default = val
break
field = FieldInfo(default=default, alias=alias)
field.annotation = annotation
fields[name] = field
cls.model_fields = fields
def __init__(self, **kwargs):
for name, field in self.__class__.model_fields.items():
if name in kwargs:
setattr(self, name, kwargs[name])
elif not field.is_required():
setattr(self, name, field.default)
@classmethod
def model_validate(cls, data):
"""Validate a dict against this schema."""
alias_gen = cls.model_config.get("alias_generator")
errors = _validate_schema(data, cls.model_fields, cls.model_config, alias_gen)
if errors:
raise ValidationError(errors)
# Build result with defaults filled in
result_data = dict(data)
for name, field in cls.model_fields.items():
data_key = name
if field.alias is not None:
data_key = field.alias
elif alias_gen:
data_key = alias_gen(name)
if data_key not in result_data and not field.is_required():
result_data[data_key] = field.default
return _ValidatedResult(result_data)
@classmethod
def from_function(
cls,
func,
*,
config=None,
):
"""Build a Schema subclass from a function's signature.
Each parameter becomes a field. The annotation is used as the type
(defaulting to ``Any`` when missing) and the default value is
preserved (parameters without defaults become required fields).
``*args`` parameters are wrapped in ``Sequence[annotation]`` and
stored under the ``VARIABLE_POSITIONAL_ARGS`` field name.
Forward-reference annotations are resolved via
``typing.get_type_hints`` against the function's module namespace.
"""
from typing import Sequence as _Seq
if config is None:
config = {"extra": "forbid", "arbitrary_types_allowed": True}
resolved = resolve_type_hints(func)
fields = {}
for param in inspect.signature(func).parameters.values():
annotation = resolved.get(param.name, param.annotation)
if annotation is inspect.Parameter.empty:
annotation = Any
if param.default is inspect.Parameter.empty:
default = ...
else:
default = param.default
if param.kind == inspect.Parameter.VAR_POSITIONAL:
annotation = _Seq[annotation] # type: ignore[valid-type]
if param.kind == inspect.Parameter.VAR_KEYWORD:
continue
field = FieldInfo(default=default)
field.annotation = annotation
fields[param.name] = field
return create_schema(
func.__name__,
__config__=config,
**{name: (f.annotation, f) for name, f in fields.items()},
)
def model_dump(self):
"""Convert instance to dict."""
result = {}
for name in self.__class__.model_fields:
if hasattr(self, name):
val = getattr(self, name)
if isinstance(val, Schema):
result[name] = val.model_dump()
else:
result[name] = val
return result
def create_schema(__name, __config__=None, **fields):
"""Dynamically create a Schema subclass.
Each field value should be a (annotation, FieldInfo) tuple.
"""
if __config__ is None:
__config__ = {"extra": "allow"}
processed = {}
annotations = {}
defaults = {}
for name, field_def in fields.items():
if isinstance(field_def, tuple) and len(field_def) == 2:
annotation, field_info = field_def
if not isinstance(field_info, FieldInfo):
field_info = FieldInfo(default=field_info)
else:
raise ValueError(f"Field {name} must be (annotation, FieldInfo) tuple")
field_info.annotation = annotation
processed[name] = field_info
annotations[name] = annotation
if not field_info.is_required():
defaults[name] = field_info.default
namespace = {
"__annotations__": annotations,
"model_config": __config__,
}
namespace.update(defaults)
cls = type(__name, (Schema,), namespace)
# Apply alias_generator to fields that don't have explicit aliases
alias_gen = __config__.get("alias_generator") if __config__ else None
if alias_gen and callable(alias_gen):
for name, field in processed.items():
if field.alias is None:
field.alias = alias_gen(name)
# Override with our processed fields (preserving aliases)
cls.model_fields = processed
return cls
# === Resolve forward references ===
def resolve_type_hints(func):
"""Resolve type hints for a function, handling forward references.
Falls back to raw annotations if resolution fails.
"""
try:
mod_name = getattr(func, "__module__", None)
module = sys.modules.get(mod_name) if mod_name else None
globalns = vars(module) if module else None
return get_type_hints(func, globalns=globalns)
except (NameError, AttributeError, TypeError, RecursionError):
# NameError: unresolvable forward reference
# AttributeError: module without expected attributes
# TypeError: invalid annotation object
# RecursionError: self-referential types (Python 3.13+)
return {}
# === Type Validation ===
def _error_type_for(annotation):
"""Get an error type string for an annotation."""
if annotation is int or annotation is PositiveInt:
return "int_parsing"
elif annotation is str:
return "string_type"
elif annotation is float or annotation is StrictFloat:
return "float_parsing"
elif annotation is bool or annotation is StrictBool:
return "bool_type"
return "value_error"
def validate_type(value, annotation):
"""Validate value against a type annotation.
Returns None if valid, or an error message string if invalid.
"""
ctx = Ctx()
if _tc2_check_type(value, annotation, ctx=ctx):
return None
if ctx.errors:
return str(ctx.errors[0])
return f"{value!r} does not match {annotation}" # pragma: no cover -- defensive fallback
# === Schema Validation ===
def _validate_schema(data, fields, config, alias_generator=None):
"""Validate a data dict against schema fields.
Returns list of error dicts (empty if valid).
"""
errors = []
extra_mode = config.get("extra", "allow")
# Build mapping: data_key -> (field_name, FieldInfo)
key_to_field = {}
known_keys = set()
for name, field in fields.items():
if field.alias is not None:
data_key = field.alias
elif alias_generator:
data_key = alias_generator(name)
else:
data_key = name
known_keys.add(data_key)
key_to_field[data_key] = (name, field)
# Check extra fields
if extra_mode == "forbid":
for key in data:
if key not in known_keys:
errors.append(
{
"loc": (key,),
"msg": "Extra inputs are not permitted",
"type": "extra_forbidden",
}
)
# Validate each field
for data_key, (name, field) in key_to_field.items():
if data_key in data:
value = data[data_key]
err = validate_type(value, field.annotation)
if err:
errors.append(
{
"loc": (data_key,),
"msg": err,
"type": _error_type_for(field.annotation),
}
)
elif field.is_required():
errors.append(
{
"loc": (data_key,),
"msg": "Field required",
"type": "missing",
}
)
return errors
# === Pydantic Compatibility Shim ===
_pydantic_cache: dict = {}
def _get_pydantic_validation_error():
"""Return the pydantic ValidationError class(es) to catch.
Tries both pydantic.v1 and pydantic so we catch the right exception
regardless of which API the caller's model was built with.
"""
errors = []
if _PydanticV1ValidationError is not None:
errors.append(_PydanticV1ValidationError)
if _PydanticV2ValidationError is not None:
errors.append(_PydanticV2ValidationError)
if errors:
return tuple(errors)
# Should never happen — we only get here if someone passed a pydantic
# model, which means pydantic is installed. Fall back to Exception so
# the except clause still works rather than crashing.
return (Exception,) # pragma: no cover
def _is_pydantic_model(cls):
"""Check if cls is a pydantic BaseModel class (v1 or v2) without hard-depending
on pydantic. Returns False if pydantic is not installed."""
if not isinstance(cls, type):
return False
if issubclass(cls, Schema):
return False
if _PydanticV1BaseModel is not None and issubclass(cls, _PydanticV1BaseModel):
return True
if _PydanticV2BaseModel is not None and issubclass(cls, _PydanticV2BaseModel):
return True
return False
def _pydantic_instance_to_dict(obj):
"""Convert a pydantic model instance to a dict."""
if hasattr(obj, "model_dump"):
return obj.model_dump()
if hasattr(obj, "dict"):
return obj.dict()
return obj
def _extract_pydantic_fields(pydantic_cls):
"""Extract field definitions from a pydantic BaseModel class (v1 or v2)."""
fields = {}
if hasattr(pydantic_cls, "model_fields"):
# pydantic v2 interface (check first — v2 also exposes __fields__
# as a deprecated shim, so we must not fall into the v1 branch)
for name, pyd_field in pydantic_cls.model_fields.items():
annotation = pyd_field.annotation
if pyd_field.is_required():
default = ...
else:
default = pyd_field.default
alias = pyd_field.alias
if isinstance(annotation, type) and _is_pydantic_model(annotation):
annotation = ensure_schema(annotation)
if default is not ... and hasattr(default, "model_dump"):
default = _pydantic_instance_to_dict(default)
field = FieldInfo(default=default, alias=alias)
field.annotation = annotation
fields[name] = field
elif hasattr(pydantic_cls, "__fields__"):
# pydantic v1 interface
for name, pyd_field in pydantic_cls.__fields__.items():
annotation = pyd_field.outer_type_
# pydantic v1 unwraps Optional[X] into outer_type_=X +
# allow_none=True. Re-wrap so our validator sees the Union.
if getattr(pyd_field, "allow_none", False):
annotation = Optional[annotation]
if pyd_field.required:
default = ...
else:
default = pyd_field.default
alias = pyd_field.alias if pyd_field.alias != name else None
# Recursively convert nested pydantic model annotations
if isinstance(annotation, type) and _is_pydantic_model(annotation):
annotation = ensure_schema(annotation)
# Convert pydantic instance defaults to dicts
if default is not ... and hasattr(default, "__fields__"):
default = _pydantic_instance_to_dict(default)
field = FieldInfo(default=default, alias=alias)
field.annotation = annotation
fields[name] = field
return fields
def _extract_pydantic_config(pydantic_cls):
"""Extract model config from a pydantic BaseModel class (v1 or v2)."""
config = {"extra": "allow"}
if hasattr(pydantic_cls, "__config__"):
# pydantic v1: inner class Config
cfg = pydantic_cls.__config__
extra = getattr(cfg, "extra", "allow")
# v1 may use an enum (e.g. Extra.forbid); extract the .value
if hasattr(extra, "value"):
extra = extra.value # pyright: ignore[reportAttributeAccessIssue]
config["extra"] = extra if isinstance(extra, str) else str(extra)
if hasattr(cfg, "arbitrary_types_allowed"):
config["arbitrary_types_allowed"] = cfg.arbitrary_types_allowed
elif hasattr(pydantic_cls, "model_config") and isinstance(
pydantic_cls.model_config, dict
):
# pydantic v2: dict
config = dict(pydantic_cls.model_config)
return config
def ensure_schema(schema_cls):
"""Ensure *schema_cls* satisfies the Schema interface.
If it already is a Schema subclass, return it unchanged.
If it is a pydantic BaseModel (v1 or v2), build a thin Schema wrapper
that exposes the same ``model_fields`` / ``model_config`` and delegates
``model_validate`` to the original pydantic class so that pydantic
validators, strict types, constrained types etc. keep working.
This allows downstream libraries (spaCy, thinc, …) to keep passing
pydantic schemas to ``registry.resolve()`` / ``registry.fill()`` even
though confection itself no longer depends on pydantic.
"""
if isinstance(schema_cls, type) and issubclass(schema_cls, Schema):
return schema_cls
if not _is_pydantic_model(schema_cls):
return schema_cls
# Return cached conversion if available
if schema_cls in _pydantic_cache:
return _pydantic_cache[schema_cls]
fields = _extract_pydantic_fields(schema_cls)
config = _extract_pydantic_config(schema_cls)
# Build wrapper class that inherits from Schema
pyd_cls = schema_cls # capture for closure
wrapper = type(pydantic_cls_name(schema_cls), (Schema,), {})
wrapper.model_fields = fields
wrapper.model_config = config
# Delegate model_validate to the original pydantic model so that
# pydantic-level validators / strict types / constraints keep working.
@classmethod # type: ignore[misc]
def _pydantic_model_validate(cls, data):
# Resolve the concrete pydantic ValidationError class once so the
# except clause is as narrow as possible.
pyd_validation_err = _get_pydantic_validation_error()
try:
if hasattr(pyd_cls, "model_validate"):
pyd_cls.model_validate(data)
elif hasattr(pyd_cls, "parse_obj"):
pyd_cls.parse_obj(data)
else: # pragma: no cover -- all pydantic versions have model_validate or parse_obj
pyd_cls(**data) # pragma: no cover
except pyd_validation_err as e:
raise ValidationError(
e.errors() # pyright: ignore[reportAttributeAccessIssue]
) from None
# Return attribute-accessible result with defaults filled in
result_data = dict(data)
for name, field in cls.model_fields.items():
data_key = field.alias if field.alias is not None else name
if data_key not in result_data and not field.is_required():
result_data[data_key] = field.default
return _ValidatedResult(result_data)
wrapper.model_validate = _pydantic_model_validate
_pydantic_cache[schema_cls] = wrapper
return wrapper
def pydantic_cls_name(cls):
return getattr(cls, "__name__", "PydanticSchema")