diff --git a/altair/utils/schemapi.py b/altair/utils/schemapi.py index 0416c3405..cf5f2e10e 100644 --- a/altair/utils/schemapi.py +++ b/altair/utils/schemapi.py @@ -7,6 +7,7 @@ import datetime as dt import inspect import json +import operator import sys import textwrap from collections import defaultdict @@ -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 @@ -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. @@ -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 diff --git a/tests/utils/test_schemapi.py b/tests/utils/test_schemapi.py index 1f847e8dd..1059b35c1 100644 --- a/tests/utils/test_schemapi.py +++ b/tests/utils/test_schemapi.py @@ -7,6 +7,7 @@ import io import json import pickle +import re import types import warnings from collections import deque @@ -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' @@ -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' @@ -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' @@ -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, @@ -764,25 +765,25 @@ 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'\] @@ -790,20 +791,20 @@ def id_func_chart_error_example(val) -> str: - 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, @@ -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, diff --git a/tools/schemapi/schemapi.py b/tools/schemapi/schemapi.py index b19ddad73..5ee638266 100644 --- a/tools/schemapi/schemapi.py +++ b/tools/schemapi/schemapi.py @@ -5,6 +5,7 @@ import datetime as dt import inspect import json +import operator import sys import textwrap from collections import defaultdict @@ -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 @@ -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. @@ -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