Skip to content

Commit 582a364

Browse files
authored
feat: Show python types in ValidationError messages (#3735)
1 parent cf73537 commit 582a364

File tree

3 files changed

+114
-51
lines changed

3 files changed

+114
-51
lines changed

altair/utils/schemapi.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import datetime as dt
88
import inspect
99
import json
10+
import operator
1011
import sys
1112
import textwrap
1213
from collections import defaultdict
@@ -48,6 +49,7 @@
4849
from types import ModuleType
4950
from typing import ClassVar
5051

52+
from jsonschema.exceptions import ValidationError
5153
from referencing import Registry
5254

5355
from altair.typing import ChartType
@@ -589,7 +591,23 @@ def _resolve_references(
589591
return schema
590592

591593

594+
def _validator_values(errors: Iterable[ValidationError], /) -> Iterator[str]:
595+
"""Unwrap each error's ``.validator_value``, convince ``mypy`` it stores a string."""
596+
for err in errors:
597+
yield cast("str", err.validator_value)
598+
599+
592600
class SchemaValidationError(jsonschema.ValidationError):
601+
_JS_TO_PY: ClassVar[Mapping[str, str]] = {
602+
"boolean": "bool",
603+
"integer": "int",
604+
"number": "float",
605+
"string": "str",
606+
"null": "None",
607+
"object": "Mapping[str, Any]",
608+
"array": "Sequence",
609+
}
610+
593611
def __init__(self, obj: SchemaBase, err: jsonschema.ValidationError) -> None:
594612
"""
595613
A wrapper for ``jsonschema.ValidationError`` with friendlier traceback.
@@ -762,26 +780,39 @@ def split_into_equal_parts(n: int, p: int) -> list[int]:
762780
param_names_table += "\n"
763781
return param_names_table
764782

783+
def _format_type_reprs(self, errors: Iterable[ValidationError], /) -> str:
784+
"""
785+
Translate jsonschema types to how they appear in annotations.
786+
787+
Adapts parts of:
788+
- `tools.schemapi.utils.sort_type_reprs`_
789+
- `tools.schemapi.utils.SchemaInfo.to_type_repr`_
790+
791+
.. _tools.schemapi.utils.sort_type_reprs:
792+
https://github.com/vega/altair/blob/48e976ef9388ce08a2e871a0f67ed012b914597a/tools/schemapi/utils.py#L1106-L1146
793+
.. _tools.schemapi.utils.SchemaInfo.to_type_repr:
794+
https://github.com/vega/altair/blob/48e976ef9388ce08a2e871a0f67ed012b914597a/tools/schemapi/utils.py#L449-L543
795+
"""
796+
to_py_types = (
797+
self._JS_TO_PY.get(val, val) for val in _validator_values(errors)
798+
)
799+
it = sorted(to_py_types, key=str.lower)
800+
it = sorted(it, key=len)
801+
it = sorted(it, key=partial(operator.eq, "None"))
802+
return f"of type `{' | '.join(it)}`"
803+
765804
def _get_default_error_message(
766805
self,
767806
errors: ValidationErrorList,
768807
) -> str:
769808
bullet_points: list[str] = []
770809
errors_by_validator = _group_errors_by_validator(errors)
771-
if "enum" in errors_by_validator:
772-
for error in errors_by_validator["enum"]:
773-
bullet_points.append(f"one of {error.validator_value}")
774-
775-
if "type" in errors_by_validator:
776-
types = [f"'{err.validator_value}'" for err in errors_by_validator["type"]]
777-
point = "of type "
778-
if len(types) == 1:
779-
point += types[0]
780-
elif len(types) == 2:
781-
point += f"{types[0]} or {types[1]}"
782-
else:
783-
point += ", ".join(types[:-1]) + f", or {types[-1]}"
784-
bullet_points.append(point)
810+
if errs_enum := errors_by_validator.get("enum", None):
811+
bullet_points.extend(
812+
f"one of {val}" for val in _validator_values(errs_enum)
813+
)
814+
if errs_type := errors_by_validator.get("type", None):
815+
bullet_points.append(self._format_type_reprs(errs_type))
785816

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

tests/utils/test_schemapi.py

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import io
88
import json
99
import pickle
10+
import re
1011
import types
1112
import warnings
1213
from collections import deque
@@ -661,7 +662,7 @@ def id_func_chart_error_example(val) -> str:
661662
chart_funcs_error_message: list[tuple[Callable[..., Any], str]] = [
662663
(
663664
chart_error_example__invalid_y_option_value_unknown_x_option,
664-
r"""Multiple errors were found.
665+
rf"""Multiple errors were found.
665666
666667
Error 1: `X` has no parameter named 'unknown'
667668
@@ -676,21 +677,21 @@ def id_func_chart_error_example(val) -> str:
676677
Error 2: 'asdf' is an invalid value for `stack`. Valid values are:
677678
678679
- One of \['zero', 'center', 'normalize'\]
679-
- Of type 'null' or 'boolean'$""",
680+
- Of type {re.escape("`bool | None`")}$""",
680681
),
681682
(
682683
chart_error_example__wrong_tooltip_type_in_faceted_chart,
683-
r"""'\['wrong'\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.$""",
684+
rf"""'\['wrong'\]' is an invalid value for `field`. Valid values are of type {re.escape("`str | Mapping[str, Any]`")}.$""",
684685
),
685686
(
686687
chart_error_example__wrong_tooltip_type_in_layered_chart,
687-
r"""'\['wrong'\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.$""",
688+
rf"""'\['wrong'\]' is an invalid value for `field`. Valid values are of type {re.escape("`str | Mapping[str, Any]`")}.$""",
688689
),
689690
(
690691
chart_error_example__two_errors_in_layered_chart,
691-
r"""Multiple errors were found.
692+
rf"""Multiple errors were found.
692693
693-
Error 1: '\['wrong'\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.
694+
Error 1: '\['wrong'\]' is an invalid value for `field`. Valid values are of type {re.escape("`str | Mapping[str, Any]`")}.
694695
695696
Error 2: `Color` has no parameter named 'invalidArgument'
696697
@@ -703,17 +704,17 @@ def id_func_chart_error_example(val) -> str:
703704
),
704705
(
705706
chart_error_example__two_errors_in_complex_concat_layered_chart,
706-
r"""Multiple errors were found.
707+
rf"""Multiple errors were found.
707708
708-
Error 1: '\['wrong'\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.
709+
Error 1: '\['wrong'\]' is an invalid value for `field`. Valid values are of type {re.escape("`str | Mapping[str, Any]`")}.
709710
710-
Error 2: '4' is an invalid value for `bandPosition`. Valid values are of type 'number'.$""",
711+
Error 2: '4' is an invalid value for `bandPosition`. Valid values are of type `float`.$""",
711712
),
712713
(
713714
chart_error_example__three_errors_in_complex_concat_layered_chart,
714-
r"""Multiple errors were found.
715+
rf"""Multiple errors were found.
715716
716-
Error 1: '\['wrong'\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.
717+
Error 1: '\['wrong'\]' is an invalid value for `field`. Valid values are of type {re.escape("`str | Mapping[str, Any]`")}.
717718
718719
Error 2: `Color` has no parameter named 'invalidArgument'
719720
@@ -724,7 +725,7 @@ def id_func_chart_error_example(val) -> str:
724725
725726
See the help for `Color` to read the full description of these parameters
726727
727-
Error 3: '4' is an invalid value for `bandPosition`. Valid values are of type 'number'.$""",
728+
Error 3: '4' is an invalid value for `bandPosition`. Valid values are of type `float`.$""",
728729
),
729730
(
730731
chart_error_example__two_errors_with_one_in_nested_layered_chart,
@@ -764,46 +765,46 @@ def id_func_chart_error_example(val) -> str:
764765
),
765766
(
766767
chart_error_example__invalid_y_option_value,
767-
r"""'asdf' is an invalid value for `stack`. Valid values are:
768+
rf"""'asdf' is an invalid value for `stack`. Valid values are:
768769
769770
- One of \['zero', 'center', 'normalize'\]
770-
- Of type 'null' or 'boolean'$""",
771+
- Of type {re.escape("`bool | None`")}$""",
771772
),
772773
(
773774
chart_error_example__invalid_y_option_value_with_condition,
774-
r"""'asdf' is an invalid value for `stack`. Valid values are:
775+
rf"""'asdf' is an invalid value for `stack`. Valid values are:
775776
776777
- One of \['zero', 'center', 'normalize'\]
777-
- Of type 'null' or 'boolean'$""",
778+
- Of type {re.escape("`bool | None`")}$""",
778779
),
779780
(
780781
chart_error_example__hconcat,
781-
r"""'{'text': 'Horsepower', 'align': 'right'}' is an invalid value for `title`. Valid values are of type 'string', 'array', or 'null'.$""",
782+
rf"""'{{'text': 'Horsepower', 'align': 'right'}}' is an invalid value for `title`. Valid values are of type {re.escape("`str | Sequence | None`")}.$""",
782783
),
783784
(
784785
chart_error_example__invalid_timeunit_value,
785-
r"""'invalid_value' is an invalid value for `timeUnit`. Valid values are:
786+
rf"""'invalid_value' is an invalid value for `timeUnit`. Valid values are:
786787
787788
- One of \['year', 'quarter', 'month', 'week', 'day', 'dayofyear', 'date', 'hours', 'minutes', 'seconds', 'milliseconds'\]
788789
- One of \['utcyear', 'utcquarter', 'utcmonth', 'utcweek', 'utcday', 'utcdayofyear', 'utcdate', 'utchours', 'utcminutes', 'utcseconds', 'utcmilliseconds'\]
789790
- 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'\]
790791
- 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'\]
791792
- One of \['binnedyear', 'binnedyearquarter', 'binnedyearquartermonth', 'binnedyearmonth', 'binnedyearmonthdate', 'binnedyearmonthdatehours', 'binnedyearmonthdatehoursminutes', 'binnedyearmonthdatehoursminutesseconds', 'binnedyearweek', 'binnedyearweekday', 'binnedyearweekdayhours', 'binnedyearweekdayhoursminutes', 'binnedyearweekdayhoursminutesseconds', 'binnedyeardayofyear'\]
792793
- One of \['binnedutcyear', 'binnedutcyearquarter', 'binnedutcyearquartermonth', 'binnedutcyearmonth', 'binnedutcyearmonthdate', 'binnedutcyearmonthdatehours', 'binnedutcyearmonthdatehoursminutes', 'binnedutcyearmonthdatehoursminutesseconds', 'binnedutcyearweek', 'binnedutcyearweekday', 'binnedutcyearweekdayhours', 'binnedutcyearweekdayhoursminutes', 'binnedutcyearweekdayhoursminutesseconds', 'binnedutcyeardayofyear'\]
793-
- Of type 'object'$""",
794+
- Of type {re.escape("`Mapping[str, Any]`")}$""",
794795
),
795796
(
796797
chart_error_example__invalid_sort_value,
797-
r"""'invalid_value' is an invalid value for `sort`. Valid values are:
798+
rf"""'invalid_value' is an invalid value for `sort`. Valid values are:
798799
799800
- One of \['ascending', 'descending'\]
800801
- One of \['x', 'y', 'color', 'fill', 'stroke', 'strokeWidth', 'size', 'shape', 'fillOpacity', 'strokeOpacity', 'opacity', 'text'\]
801802
- One of \['-x', '-y', '-color', '-fill', '-stroke', '-strokeWidth', '-size', '-shape', '-fillOpacity', '-strokeOpacity', '-opacity', '-text'\]
802-
- Of type 'array', 'object', or 'null'$""",
803+
- Of type {re.escape("`Sequence | Mapping[str, Any] | None`")}$""",
803804
),
804805
(
805806
chart_error_example__invalid_bandposition_value,
806-
r"""'4' is an invalid value for `bandPosition`. Valid values are of type 'number'.$""",
807+
r"""'4' is an invalid value for `bandPosition`. Valid values are of type `float`.$""",
807808
),
808809
(
809810
chart_error_example__invalid_type,
@@ -823,7 +824,7 @@ def id_func_chart_error_example(val) -> str:
823824
),
824825
(
825826
chart_error_example__invalid_value_type,
826-
r"""'1' is an invalid value for `value`. Valid values are of type 'object', 'string', or 'null'.$""",
827+
rf"""'1' is an invalid value for `value`. Valid values are of type {re.escape("`str | Mapping[str, Any] | None`")}.$""",
827828
),
828829
(
829830
chart_error_example__four_errors_hide_fourth,

tools/schemapi/schemapi.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import datetime as dt
66
import inspect
77
import json
8+
import operator
89
import sys
910
import textwrap
1011
from collections import defaultdict
@@ -46,6 +47,7 @@
4647
from types import ModuleType
4748
from typing import ClassVar
4849

50+
from jsonschema.exceptions import ValidationError
4951
from referencing import Registry
5052

5153
from altair.typing import ChartType
@@ -587,7 +589,23 @@ def _resolve_references(
587589
return schema
588590

589591

592+
def _validator_values(errors: Iterable[ValidationError], /) -> Iterator[str]:
593+
"""Unwrap each error's ``.validator_value``, convince ``mypy`` it stores a string."""
594+
for err in errors:
595+
yield cast("str", err.validator_value)
596+
597+
590598
class SchemaValidationError(jsonschema.ValidationError):
599+
_JS_TO_PY: ClassVar[Mapping[str, str]] = {
600+
"boolean": "bool",
601+
"integer": "int",
602+
"number": "float",
603+
"string": "str",
604+
"null": "None",
605+
"object": "Mapping[str, Any]",
606+
"array": "Sequence",
607+
}
608+
591609
def __init__(self, obj: SchemaBase, err: jsonschema.ValidationError) -> None:
592610
"""
593611
A wrapper for ``jsonschema.ValidationError`` with friendlier traceback.
@@ -760,26 +778,39 @@ def split_into_equal_parts(n: int, p: int) -> list[int]:
760778
param_names_table += "\n"
761779
return param_names_table
762780

781+
def _format_type_reprs(self, errors: Iterable[ValidationError], /) -> str:
782+
"""
783+
Translate jsonschema types to how they appear in annotations.
784+
785+
Adapts parts of:
786+
- `tools.schemapi.utils.sort_type_reprs`_
787+
- `tools.schemapi.utils.SchemaInfo.to_type_repr`_
788+
789+
.. _tools.schemapi.utils.sort_type_reprs:
790+
https://github.com/vega/altair/blob/48e976ef9388ce08a2e871a0f67ed012b914597a/tools/schemapi/utils.py#L1106-L1146
791+
.. _tools.schemapi.utils.SchemaInfo.to_type_repr:
792+
https://github.com/vega/altair/blob/48e976ef9388ce08a2e871a0f67ed012b914597a/tools/schemapi/utils.py#L449-L543
793+
"""
794+
to_py_types = (
795+
self._JS_TO_PY.get(val, val) for val in _validator_values(errors)
796+
)
797+
it = sorted(to_py_types, key=str.lower)
798+
it = sorted(it, key=len)
799+
it = sorted(it, key=partial(operator.eq, "None"))
800+
return f"of type `{' | '.join(it)}`"
801+
763802
def _get_default_error_message(
764803
self,
765804
errors: ValidationErrorList,
766805
) -> str:
767806
bullet_points: list[str] = []
768807
errors_by_validator = _group_errors_by_validator(errors)
769-
if "enum" in errors_by_validator:
770-
for error in errors_by_validator["enum"]:
771-
bullet_points.append(f"one of {error.validator_value}")
772-
773-
if "type" in errors_by_validator:
774-
types = [f"'{err.validator_value}'" for err in errors_by_validator["type"]]
775-
point = "of type "
776-
if len(types) == 1:
777-
point += types[0]
778-
elif len(types) == 2:
779-
point += f"{types[0]} or {types[1]}"
780-
else:
781-
point += ", ".join(types[:-1]) + f", or {types[-1]}"
782-
bullet_points.append(point)
808+
if errs_enum := errors_by_validator.get("enum", None):
809+
bullet_points.extend(
810+
f"one of {val}" for val in _validator_values(errs_enum)
811+
)
812+
if errs_type := errors_by_validator.get("type", None):
813+
bullet_points.append(self._format_type_reprs(errs_type))
783814

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

0 commit comments

Comments
 (0)