Open
Description
The plan is to gradually migrate towards an API that is at least compatible in theory and usage (if not physically as in class hierarchy) with PEP 654 ExceptionGroups. This has a lot of benefits:
- Already established API. Less design work for us, less learning for users. We can even point them at the Python docs for how the thing works in general.
- Established(ing) tooling. I expect that Rich and many other things will support rich rending or tracebacks from ExceptionGroups by default.
- More powerful API. If we move locs to the ValidationError itself and make LineError a public ExceptionGroup in its own right we'll have a really powerful API (and even syntax, thanks to PEP 654!) to traverse and filter Pydantic's errors. I know @hoodmane had been asking for this. We can easily (and relatively performantly I expect) provide methods for iterating through exceptions as a flat list, as a tree and with original or resolved
loc
's.
Here's my draft for the target API:
from __future__ import annotations
import json
from typing import (
Any,
Callable,
Literal,
Mapping,
Sequence,
TypeAlias,
TypedDict,
)
from typing_extensions import final
KnownError = Literal["string_type"]
@final
class LineError(Exception):
def __init__(
self,
input: Any,
type: str,
message_template: str,
ctx: Mapping[str, Any],
) -> None:
self.input = input
self.type = type
self.message = message_template.format(**ctx)
@staticmethod
def known(type: KnownError, input: Any, ctx: Mapping[str, Any]) -> LineError:
...
@staticmethod
def custom(
type: str, input: Any, message_template: str, ctx: Mapping[str, Any]
) -> LineError:
...
class ErrorDetail(TypedDict):
pass
ValidationErrorExc = type[LineError]
ExcT: TypeAlias = ValueError | AssertionError | LineError | "ValidationError"
TypeCondition = ExcT | tuple[ExcT, ...]
CallableCondition = Callable[[ExcT], bool]
class ValidationError(ValueError):
def __init__(
self,
message: str,
errors: Sequence[ExcT],
loc_prefix: tuple[int | str, ...] = (),
) -> None:
self._message = message
self._errors = errors
self._loc_prefix = loc_prefix
def errors(self) -> list[ErrorDetail]:
...
def json(self) -> bytes:
return json.dumps(self.errors()).encode()
@property
def loc_prefix(self) -> tuple[int | str, ...]:
return self._loc_prefix
@property
def exceptions(
self,
) -> list[ExcT]:
...
def subgroup(self, __condition: TypeCondition | CallableCondition) -> ValidationError | None:
...
def split(
self, __condition: TypeCondition | CallableCondition
) -> tuple[ValidationError, ValidationError]:
...
@property
def message(self) -> str:
return "Validation error"
def derive(self, __excs: Sequence[ExcT]) -> ValidationError:
return ValidationError(
self.message,
__excs,
loc_prefix=self.loc_prefix,
)
v1 = ValidationError(
"error",
[
LineError.known(
type="string_type",
input="foo",
ctx={"min_length": 10},
),
LineError.custom(
type="form_parse",
input="a=1&b=2&c=3,4",
message_template="Could not parse form w/ style={style}",
ctx={"style": "form"},
),
ValueError("Bad bad int!"),
]
)
v2 = ValidationError(
"error2",
v1.exceptions,
)
One thing omitted here is the InitError
types we currently have. I think we can do without those but we should keep them around for now to minimize changes, and maybe forever.