Skip to content

Commit

Permalink
feat: Show python types in ValidationError messages (#3735)
Browse files Browse the repository at this point in the history
  • Loading branch information
dangotbanned authored Jan 2, 2025
1 parent cf73537 commit 582a364
Show file tree
Hide file tree
Showing 3 changed files with 114 additions and 51 deletions.
59 changes: 45 additions & 14 deletions altair/utils/schemapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import datetime as dt
import inspect
import json
import operator
import sys
import textwrap
from collections import defaultdict
Expand Down Expand Up @@ -48,6 +49,7 @@
from types import ModuleType
from typing import ClassVar

from jsonschema.exceptions import ValidationError
from referencing import Registry

from altair.typing import ChartType
Expand Down Expand Up @@ -589,7 +591,23 @@ def _resolve_references(
return schema


def _validator_values(errors: Iterable[ValidationError], /) -> Iterator[str]:
"""Unwrap each error's ``.validator_value``, convince ``mypy`` it stores a string."""
for err in errors:
yield cast("str", err.validator_value)


class SchemaValidationError(jsonschema.ValidationError):
_JS_TO_PY: ClassVar[Mapping[str, str]] = {
"boolean": "bool",
"integer": "int",
"number": "float",
"string": "str",
"null": "None",
"object": "Mapping[str, Any]",
"array": "Sequence",
}

def __init__(self, obj: SchemaBase, err: jsonschema.ValidationError) -> None:
"""
A wrapper for ``jsonschema.ValidationError`` with friendlier traceback.
Expand Down Expand Up @@ -762,26 +780,39 @@ def split_into_equal_parts(n: int, p: int) -> list[int]:
param_names_table += "\n"
return param_names_table

def _format_type_reprs(self, errors: Iterable[ValidationError], /) -> str:
"""
Translate jsonschema types to how they appear in annotations.
Adapts parts of:
- `tools.schemapi.utils.sort_type_reprs`_
- `tools.schemapi.utils.SchemaInfo.to_type_repr`_
.. _tools.schemapi.utils.sort_type_reprs:
https://github.com/vega/altair/blob/48e976ef9388ce08a2e871a0f67ed012b914597a/tools/schemapi/utils.py#L1106-L1146
.. _tools.schemapi.utils.SchemaInfo.to_type_repr:
https://github.com/vega/altair/blob/48e976ef9388ce08a2e871a0f67ed012b914597a/tools/schemapi/utils.py#L449-L543
"""
to_py_types = (
self._JS_TO_PY.get(val, val) for val in _validator_values(errors)
)
it = sorted(to_py_types, key=str.lower)
it = sorted(it, key=len)
it = sorted(it, key=partial(operator.eq, "None"))
return f"of type `{' | '.join(it)}`"

def _get_default_error_message(
self,
errors: ValidationErrorList,
) -> str:
bullet_points: list[str] = []
errors_by_validator = _group_errors_by_validator(errors)
if "enum" in errors_by_validator:
for error in errors_by_validator["enum"]:
bullet_points.append(f"one of {error.validator_value}")

if "type" in errors_by_validator:
types = [f"'{err.validator_value}'" for err in errors_by_validator["type"]]
point = "of type "
if len(types) == 1:
point += types[0]
elif len(types) == 2:
point += f"{types[0]} or {types[1]}"
else:
point += ", ".join(types[:-1]) + f", or {types[-1]}"
bullet_points.append(point)
if errs_enum := errors_by_validator.get("enum", None):
bullet_points.extend(
f"one of {val}" for val in _validator_values(errs_enum)
)
if errs_type := errors_by_validator.get("type", None):
bullet_points.append(self._format_type_reprs(errs_type))

# It should not matter which error is specifically used as they are all
# about the same offending instance (i.e. invalid value), so we can just
Expand Down
47 changes: 24 additions & 23 deletions tests/utils/test_schemapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import io
import json
import pickle
import re
import types
import warnings
from collections import deque
Expand Down Expand Up @@ -661,7 +662,7 @@ def id_func_chart_error_example(val) -> str:
chart_funcs_error_message: list[tuple[Callable[..., Any], str]] = [
(
chart_error_example__invalid_y_option_value_unknown_x_option,
r"""Multiple errors were found.
rf"""Multiple errors were found.
Error 1: `X` has no parameter named 'unknown'
Expand All @@ -676,21 +677,21 @@ def id_func_chart_error_example(val) -> str:
Error 2: 'asdf' is an invalid value for `stack`. Valid values are:
- One of \['zero', 'center', 'normalize'\]
- Of type 'null' or 'boolean'$""",
- Of type {re.escape("`bool | None`")}$""",
),
(
chart_error_example__wrong_tooltip_type_in_faceted_chart,
r"""'\['wrong'\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.$""",
rf"""'\['wrong'\]' is an invalid value for `field`. Valid values are of type {re.escape("`str | Mapping[str, Any]`")}.$""",
),
(
chart_error_example__wrong_tooltip_type_in_layered_chart,
r"""'\['wrong'\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.$""",
rf"""'\['wrong'\]' is an invalid value for `field`. Valid values are of type {re.escape("`str | Mapping[str, Any]`")}.$""",
),
(
chart_error_example__two_errors_in_layered_chart,
r"""Multiple errors were found.
rf"""Multiple errors were found.
Error 1: '\['wrong'\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.
Error 1: '\['wrong'\]' is an invalid value for `field`. Valid values are of type {re.escape("`str | Mapping[str, Any]`")}.
Error 2: `Color` has no parameter named 'invalidArgument'
Expand All @@ -703,17 +704,17 @@ def id_func_chart_error_example(val) -> str:
),
(
chart_error_example__two_errors_in_complex_concat_layered_chart,
r"""Multiple errors were found.
rf"""Multiple errors were found.
Error 1: '\['wrong'\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.
Error 1: '\['wrong'\]' is an invalid value for `field`. Valid values are of type {re.escape("`str | Mapping[str, Any]`")}.
Error 2: '4' is an invalid value for `bandPosition`. Valid values are of type 'number'.$""",
Error 2: '4' is an invalid value for `bandPosition`. Valid values are of type `float`.$""",
),
(
chart_error_example__three_errors_in_complex_concat_layered_chart,
r"""Multiple errors were found.
rf"""Multiple errors were found.
Error 1: '\['wrong'\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.
Error 1: '\['wrong'\]' is an invalid value for `field`. Valid values are of type {re.escape("`str | Mapping[str, Any]`")}.
Error 2: `Color` has no parameter named 'invalidArgument'
Expand All @@ -724,7 +725,7 @@ def id_func_chart_error_example(val) -> str:
See the help for `Color` to read the full description of these parameters
Error 3: '4' is an invalid value for `bandPosition`. Valid values are of type 'number'.$""",
Error 3: '4' is an invalid value for `bandPosition`. Valid values are of type `float`.$""",
),
(
chart_error_example__two_errors_with_one_in_nested_layered_chart,
Expand Down Expand Up @@ -764,46 +765,46 @@ def id_func_chart_error_example(val) -> str:
),
(
chart_error_example__invalid_y_option_value,
r"""'asdf' is an invalid value for `stack`. Valid values are:
rf"""'asdf' is an invalid value for `stack`. Valid values are:
- One of \['zero', 'center', 'normalize'\]
- Of type 'null' or 'boolean'$""",
- Of type {re.escape("`bool | None`")}$""",
),
(
chart_error_example__invalid_y_option_value_with_condition,
r"""'asdf' is an invalid value for `stack`. Valid values are:
rf"""'asdf' is an invalid value for `stack`. Valid values are:
- One of \['zero', 'center', 'normalize'\]
- Of type 'null' or 'boolean'$""",
- Of type {re.escape("`bool | None`")}$""",
),
(
chart_error_example__hconcat,
r"""'{'text': 'Horsepower', 'align': 'right'}' is an invalid value for `title`. Valid values are of type 'string', 'array', or 'null'.$""",
rf"""'{{'text': 'Horsepower', 'align': 'right'}}' is an invalid value for `title`. Valid values are of type {re.escape("`str | Sequence | None`")}.$""",
),
(
chart_error_example__invalid_timeunit_value,
r"""'invalid_value' is an invalid value for `timeUnit`. Valid values are:
rf"""'invalid_value' is an invalid value for `timeUnit`. Valid values are:
- One of \['year', 'quarter', 'month', 'week', 'day', 'dayofyear', 'date', 'hours', 'minutes', 'seconds', 'milliseconds'\]
- One of \['utcyear', 'utcquarter', 'utcmonth', 'utcweek', 'utcday', 'utcdayofyear', 'utcdate', 'utchours', 'utcminutes', 'utcseconds', 'utcmilliseconds'\]
- One of \['yearquarter', 'yearquartermonth', 'yearmonth', 'yearmonthdate', 'yearmonthdatehours', 'yearmonthdatehoursminutes', 'yearmonthdatehoursminutesseconds', 'yearweek', 'yearweekday', 'yearweekdayhours', 'yearweekdayhoursminutes', 'yearweekdayhoursminutesseconds', 'yeardayofyear', 'quartermonth', 'monthdate', 'monthdatehours', 'monthdatehoursminutes', 'monthdatehoursminutesseconds', 'weekday', 'weekdayhours', 'weekdayhoursminutes', 'weekdayhoursminutesseconds', 'dayhours', 'dayhoursminutes', 'dayhoursminutesseconds', 'hoursminutes', 'hoursminutesseconds', 'minutesseconds', 'secondsmilliseconds'\]
- One of \['utcyearquarter', 'utcyearquartermonth', 'utcyearmonth', 'utcyearmonthdate', 'utcyearmonthdatehours', 'utcyearmonthdatehoursminutes', 'utcyearmonthdatehoursminutesseconds', 'utcyearweek', 'utcyearweekday', 'utcyearweekdayhours', 'utcyearweekdayhoursminutes', 'utcyearweekdayhoursminutesseconds', 'utcyeardayofyear', 'utcquartermonth', 'utcmonthdate', 'utcmonthdatehours', 'utcmonthdatehoursminutes', 'utcmonthdatehoursminutesseconds', 'utcweekday', 'utcweekdayhours', 'utcweekdayhoursminutes', 'utcweekdayhoursminutesseconds', 'utcdayhours', 'utcdayhoursminutes', 'utcdayhoursminutesseconds', 'utchoursminutes', 'utchoursminutesseconds', 'utcminutesseconds', 'utcsecondsmilliseconds'\]
- One of \['binnedyear', 'binnedyearquarter', 'binnedyearquartermonth', 'binnedyearmonth', 'binnedyearmonthdate', 'binnedyearmonthdatehours', 'binnedyearmonthdatehoursminutes', 'binnedyearmonthdatehoursminutesseconds', 'binnedyearweek', 'binnedyearweekday', 'binnedyearweekdayhours', 'binnedyearweekdayhoursminutes', 'binnedyearweekdayhoursminutesseconds', 'binnedyeardayofyear'\]
- One of \['binnedutcyear', 'binnedutcyearquarter', 'binnedutcyearquartermonth', 'binnedutcyearmonth', 'binnedutcyearmonthdate', 'binnedutcyearmonthdatehours', 'binnedutcyearmonthdatehoursminutes', 'binnedutcyearmonthdatehoursminutesseconds', 'binnedutcyearweek', 'binnedutcyearweekday', 'binnedutcyearweekdayhours', 'binnedutcyearweekdayhoursminutes', 'binnedutcyearweekdayhoursminutesseconds', 'binnedutcyeardayofyear'\]
- Of type 'object'$""",
- Of type {re.escape("`Mapping[str, Any]`")}$""",
),
(
chart_error_example__invalid_sort_value,
r"""'invalid_value' is an invalid value for `sort`. Valid values are:
rf"""'invalid_value' is an invalid value for `sort`. Valid values are:
- One of \['ascending', 'descending'\]
- One of \['x', 'y', 'color', 'fill', 'stroke', 'strokeWidth', 'size', 'shape', 'fillOpacity', 'strokeOpacity', 'opacity', 'text'\]
- One of \['-x', '-y', '-color', '-fill', '-stroke', '-strokeWidth', '-size', '-shape', '-fillOpacity', '-strokeOpacity', '-opacity', '-text'\]
- Of type 'array', 'object', or 'null'$""",
- Of type {re.escape("`Sequence | Mapping[str, Any] | None`")}$""",
),
(
chart_error_example__invalid_bandposition_value,
r"""'4' is an invalid value for `bandPosition`. Valid values are of type 'number'.$""",
r"""'4' is an invalid value for `bandPosition`. Valid values are of type `float`.$""",
),
(
chart_error_example__invalid_type,
Expand All @@ -823,7 +824,7 @@ def id_func_chart_error_example(val) -> str:
),
(
chart_error_example__invalid_value_type,
r"""'1' is an invalid value for `value`. Valid values are of type 'object', 'string', or 'null'.$""",
rf"""'1' is an invalid value for `value`. Valid values are of type {re.escape("`str | Mapping[str, Any] | None`")}.$""",
),
(
chart_error_example__four_errors_hide_fourth,
Expand Down
59 changes: 45 additions & 14 deletions tools/schemapi/schemapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import datetime as dt
import inspect
import json
import operator
import sys
import textwrap
from collections import defaultdict
Expand Down Expand Up @@ -46,6 +47,7 @@
from types import ModuleType
from typing import ClassVar

from jsonschema.exceptions import ValidationError
from referencing import Registry

from altair.typing import ChartType
Expand Down Expand Up @@ -587,7 +589,23 @@ def _resolve_references(
return schema


def _validator_values(errors: Iterable[ValidationError], /) -> Iterator[str]:
"""Unwrap each error's ``.validator_value``, convince ``mypy`` it stores a string."""
for err in errors:
yield cast("str", err.validator_value)


class SchemaValidationError(jsonschema.ValidationError):
_JS_TO_PY: ClassVar[Mapping[str, str]] = {
"boolean": "bool",
"integer": "int",
"number": "float",
"string": "str",
"null": "None",
"object": "Mapping[str, Any]",
"array": "Sequence",
}

def __init__(self, obj: SchemaBase, err: jsonschema.ValidationError) -> None:
"""
A wrapper for ``jsonschema.ValidationError`` with friendlier traceback.
Expand Down Expand Up @@ -760,26 +778,39 @@ def split_into_equal_parts(n: int, p: int) -> list[int]:
param_names_table += "\n"
return param_names_table

def _format_type_reprs(self, errors: Iterable[ValidationError], /) -> str:
"""
Translate jsonschema types to how they appear in annotations.
Adapts parts of:
- `tools.schemapi.utils.sort_type_reprs`_
- `tools.schemapi.utils.SchemaInfo.to_type_repr`_
.. _tools.schemapi.utils.sort_type_reprs:
https://github.com/vega/altair/blob/48e976ef9388ce08a2e871a0f67ed012b914597a/tools/schemapi/utils.py#L1106-L1146
.. _tools.schemapi.utils.SchemaInfo.to_type_repr:
https://github.com/vega/altair/blob/48e976ef9388ce08a2e871a0f67ed012b914597a/tools/schemapi/utils.py#L449-L543
"""
to_py_types = (
self._JS_TO_PY.get(val, val) for val in _validator_values(errors)
)
it = sorted(to_py_types, key=str.lower)
it = sorted(it, key=len)
it = sorted(it, key=partial(operator.eq, "None"))
return f"of type `{' | '.join(it)}`"

def _get_default_error_message(
self,
errors: ValidationErrorList,
) -> str:
bullet_points: list[str] = []
errors_by_validator = _group_errors_by_validator(errors)
if "enum" in errors_by_validator:
for error in errors_by_validator["enum"]:
bullet_points.append(f"one of {error.validator_value}")

if "type" in errors_by_validator:
types = [f"'{err.validator_value}'" for err in errors_by_validator["type"]]
point = "of type "
if len(types) == 1:
point += types[0]
elif len(types) == 2:
point += f"{types[0]} or {types[1]}"
else:
point += ", ".join(types[:-1]) + f", or {types[-1]}"
bullet_points.append(point)
if errs_enum := errors_by_validator.get("enum", None):
bullet_points.extend(
f"one of {val}" for val in _validator_values(errs_enum)
)
if errs_type := errors_by_validator.get("type", None):
bullet_points.append(self._format_type_reprs(errs_type))

# It should not matter which error is specifically used as they are all
# about the same offending instance (i.e. invalid value), so we can just
Expand Down

0 comments on commit 582a364

Please sign in to comment.