from __future__ import annotations from dataclasses import dataclass from functools import cache from typing import Any, Protocol, TypedDict from pydantic import BaseModel, ConfigDict, create_model from pydantic.alias_generators import to_camel from pydantic.fields import FieldInfo # noqa from pydantic_core import ErrorDetails class FansifterErrorProtocol(Protocol): code: str message: str additional_properties: tuple[tuple[str, type], ...] class FansifterError(Exception): code = "fansifter_error" message = "Fansifter error" status_code = 500 additional_properties: tuple[tuple[str, type], ...] = () def __init__(self, message: str | None = None, *, code: str | None = None) -> None: if message is not None: self.message = message if code is not None: self.code = code message = f"{self.code}: {self.message}" super().__init__(message) @classmethod @cache def model(cls) -> type[BaseModel]: """Create a pydantic model for this exception.""" return create_error_model(cls) def dump(self) -> dict[str, Any]: """Dump this exception as a dict.""" return ( self.model() .model_validate(self, from_attributes=True) .model_dump(by_alias=True) ) def create_error_model(cls: type[FansifterErrorProtocol]) -> type[BaseModel]: field_definitions: dict[str, Any] = { field_name: (field_type, ...) for field_name, field_type in cls.additional_properties } return create_model( cls.__name__, __config__=ConfigDict(populate_by_name=True, alias_generator=to_camel), code=(str, FieldInfo(json_schema_extra={"const": cls.code})), message=(str, ...), **field_definitions, ) @dataclass(frozen=True) class Error: code: str message: str class ErrorDict(TypedDict): code: str message: str class InvalidInputError(FansifterError): code = "invalid_input" message = "Invalid input" status_code = 400 additional_properties = (("field_errors", dict[str, Error]),) def __init__( self, message: str | None = None, *, code: str | None = None, field_errors: dict[str, Error | ErrorDict] | None = None, status_code: int | None = None, ) -> None: super().__init__(message, code=code) self.field_errors = field_errors or {} if status_code is not None: self.status_code = status_code def has_field_errors(self) -> bool: return bool(self.field_errors) def add_field_error( self, field_name: str, error: Error | ErrorDict, raise_exception: bool = False, ) -> None: if isinstance(error, Error): message = error.message code = error.code else: message = error["message"] code = error["code"] self.field_errors[field_name] = Error(code=code, message=message) if raise_exception: raise self @classmethod def from_pydantic( cls, errors: list[ErrorDetails], loc_pos: int = 0, status_code: int | None = None, ) -> InvalidInputError: return cls( field_errors={ cls._flatten_loc(error["loc"][loc_pos:]): Error( code=error["type"], message=error["msg"], ) for error in errors }, status_code=status_code, ) @staticmethod def _flatten_loc(loc: tuple[int | str, ...]) -> str: return ".".join(map(str, loc)) class ObjectNotFoundError(FansifterError): code = "object_not_found" message = "Object not found" status_code = 404 class ObjectConflictError(FansifterError): code = "object_conflict" message = "Object conflict" status_code = 409