Repository: altair-viz/altair

Issue:
Make `SchemaValidationError` more helpful by printing expected parameters
When a non-existing parameter name is used, I think it would be helpful to include the existing parameter names in the error message. For example, when misspelling a parameter name like in the example below it is not immediately clear whether I made a typo or something else went wrong, so the immediate feedback of seeing the correct parameter names would be helpful.

```python
import altair as alt
import pandas as pd

source = pd.DataFrame({
    'a': ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'],
    'b': [28, 55, 43, 91, 81, 53, 19, 87, 52]
})

alt.Chart(source).mark_bar().encode(
    x=alt.X("a", scale=alt.Scale(padingOuter=0.5)),
    y=alt.Y("b")
)
```

```
SchemaValidationError: Invalid specification

        altair.vegalite.v4.schema.core.Scale, validating 'additionalProperties'

        Additional properties are not allowed ('padingOuter' was unexpected)
```

I also belive the error message text could be clarified a little. Especially for novices, I don't think it is immediately clear what it means that "Additional properties are not allowed" and it could helpful to update this error message to something like:

```
SchemaValidationError: Invalid specification

        altair.vegalite.v4.schema.core.Scale, validating 'additionalProperties'

        No such parameter name exists: 'padingOuter'
        
        Existing parameter names are: 'align', 'base', 'bins', 'clamp', 'constant', 'domain',
            'domainMid', 'exponent', 'interpolate', 'nice', 'padding', 'paddingInner', 'paddingOuter',
            'range', 'reverse', 'round', 'scheme', 'type', 'zero', 'kwds'
        
        See the help for altair.Scale` for the full description of these parameters.
```

We can use the `inspect` module to retrieve the list of expected parameter names via `list(inspect.signature(altair.vegalite.v4.schema.core.Scale).parameters.keys())`. I can try to PR this if it sounds like a good idea (it looks like modifying the returned value of [altair/utils/schemapi.py](https://github.com/altair-viz/altair/blob/55bb4b7df07996abb4ff05e80a69cadae291482a/altair/utils/schemapi.py#L116-L123) is the right place?).

Discussion:
joelostblom: I rebased on master so that the checks are passing (the many force pushed are for black and flake8, sorry about the noise). I also fixed the formatting of the parameters in the error message. It required more code than expected to make an even table with names sorted alphabetically in columns, but I think that the output is much easier to read now.

Instead of the unformated list of parameters:

```
SchemaValidationError: Invalid specification

altair.vegalite.v4.schema.core.Axis, validating 'additionalProperties'

altair.Axis has no parameter named 'here'

Existing parameter names are:
'aria', 'bandPosition', 'description', 'domain', 'domainCap', 'domainColor', 'domainDash',
'domainDashOffset', 'domainOpacity', 'domainWidth', 'format',
'formatType', 'grid', 'gridCap', 'gridColor', 'gridDash',
'gridDashOffset', 'gridOpacity', 'gridWidth', 'labelAlign', 'labelAngle',
'labelBaseline', 'labelBound', 'labelColor', 'labelExpr', 'labelFlush',
'labelFlushOffset', 'labelFont', 'labelFontSize', 'labelFontStyle',
'labelFontWeight', 'labelLimit', 'labelLineHeight', 'labelOffset',
'labelOpacity', 'labelOverlap', 'labelPadding', 'labelSeparation',
'labels', 'maxExtent', 'minExtent', 'offset', 'orient', 'position',
'style', 'tickBand', 'tickCap', 'tickColor', 'tickCount', 'tickDash',
'tickDashOffset', 'tickExtra', 'tickMinStep', 'tickOffset', 'tickOpacity',
'tickRound', 'tickSize', 'tickWidth', 'ticks', 'title', 'titleAlign',
'titleAnchor', 'titleAngle', 'titleBaseline', 'titleColor', 'titleFont',
'titleFontSize', 'titleFontStyle', 'titleFontWeight', 'titleLimit',
'titleLineHeight', 'titleOpacity', 'titlePadding', 'titleX', 'titleY',
'translate', 'values', 'zindex'

See the help for altair.Axis to read the full description of these parameters
```

The output is now a table of parameters:

```
altair.vegalite.v4.schema.core.Axis, validating 'additionalProperties'

altair.Axis has no parameter named 'here'

Existing parameter names are:
aria               gridDashOffset     labelLineHeight   tickCount        titleBaseline     
bandPosition       gridOpacity        labelOffset       tickDash         titleColor        
description        gridWidth          labelOpacity      tickDashOffset   titleFont         
domain             labelAlign         labelOverlap      tickExtra        titleFontSize     
domainCap          labelAngle         labelPadding      tickMinStep      titleFontStyle    
domainColor        labelBaseline      labelSeparation   tickOffset       titleFontWeight   
domainDash         labelBound         labels            tickOpacity      titleLimit        
domainDashOffset   labelColor         maxExtent         tickRound        titleLineHeight   
domainOpacity      labelExpr          minExtent         tickSize         titleOpacity      
domainWidth        labelFlush         offset            tickWidth        titlePadding      
format             labelFlushOffset   orient            ticks            titleX            
formatType         labelFont          position          title            titleY            
grid               labelFontSize      style             titleAlign       translate         
gridCap            labelFontStyle     tickBand          titleAnchor      values            
gridColor          labelFontWeight    tickCap           titleAngle       zindex            
gridDash           labelLimit         tickColor                                            

See the help for altair.Axis to read the full description of these parameters
```

I did some basic manual testing with all the encoding channels listed in https://altair-viz.github.io/user_guide/encoding.html#encoding-channels and it seems to work fine. Although I am not sure if there are edge cases, I hope that the fallback to the old method could guard again those, but let me know if you think of anything specific and I can try to add tests for that.


joelostblom: I just noted that this fails when there is a condition in the spec. For example, the following spec:

```py
import altair as alt
from vega_datasets import data

source = data.barley()

alt.Chart(source).mark_bar().encode(
    x='variety',
    y=alt.Y('sum(yield)', stack='asdf'),
    opacity=alt.condition('datum.yield > 0', alt.value(1), alt.value(0.2))
)
```

returns:

```
SchemaValidationError: Invalid specification

altair.vegalite.v5.schema.channels.OpacityValue, validating 'additionalProperties'

altair.OpacityValue has no parameter named 'test'

Existing parameter names are:
value   condition   

See the help for altair.OpacityValue to read the full description of these parameters
```

If we change to `opacity=alt.value(0.6)` we get the expected output:

```
SchemaValidationError: Invalid specification
            altair.vegalite.v5.schema.channels.Y->0, validating 'enum'
            'asdf' is not one of ['zero', 'center', 'normalize']
```
mattijn: Hi @joelostblom can you synchronise your branch, https://github.com/joelostblom/altair/tree/schema-validation-error, with the main repo and maybe add a code-snippet how to this can be tested easily using eg. colab. This will help the review process. Hopefully @jakevdp is still willing to do this.
joelostblom: @mattijn I rebased this on the latest main branch and also structured the code so that it is easier to review. Do you think you would be able to have a look at this? I think it could be quite useful to get feedback on in the RC. I have included the rationale and an example below:

When a non-existing parameter name is used, I think it would be helpful to include the existing parameter names in the error message. Currently, when misspelling a parameter name like in the example below, it is not clear whether I made a typo or the parameter doesn't exist at all, so the immediate feedback of seeing the correct parameter names would be helpful.

```python
import altair as alt
import pandas as pd

source = pd.DataFrame({
    'a': ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'],
    'b': [28, 55, 43, 91, 81, 53, 19, 87, 52]
})

alt.Chart(source).mark_bar().encode(
    x=alt.X("a", scale=alt.Scale(padingOuter=0.5)),
    y=alt.Y("b")
)
```

```
SchemaValidationError: Invalid specification

        altair.vegalite.v4.schema.core.Scale, validating 'additionalProperties'

        Additional properties are not allowed ('padingOuter' was unexpected)
```

I also belive the error message text could be clarified a little. Especially for novices, I don't think it is immediately clear what it means that "Additional properties are not allowed". This PR updates the error message to instead read:

```
SchemaValidationError: Invalid specification

altair.vegalite.v5.schema.core.Scale, validating 'additionalProperties'

altair.Scale has no parameter named 'padingOuter'

Existing parameter names are:
align      domain      interpolate    range      round    
base       domainMax   nice           rangeMax   scheme   
bins       domainMid   padding        rangeMin   type     
clamp      domainMin   paddingInner   reverse    zero     
constant   exponent    paddingOuter                       

See the help for altair.Scale to read the full description of these parameters
```

It is possible that we could even remove the two first lines and start with `altair.Scale has no...` as I don't think the two first lines add any info that helps the user solve the error, what do you think?

This PR also includes a fallback to the old method of displaying error messages, so that in case the assumption in the if-statement does not hold true, things will not break and the user will just see a less helpful error message. In my testing, I have never seen this actually happen, but it is there just in case.
binste: This looks super helpful! Haven't reviewed the code but just a quick reminder that the changes need to be moved to `tools/schemapi/schemapi.py`.
joelostblom: Thanks @binste, updated!
binste: Just a note that it might make sense to merge this PR after #2842 and test it again. I'd expect that your changes work in even more cases as the correct `additionalProperties` error is raised more often, but just to be sure.
joelostblom: Good catch, thank you! I have updated to added the changes to the tools script as well

Patch:
diff --git a/altair/utils/core.py b/altair/utils/core.py
index 38bf0950..5e438151 100644
--- a/altair/utils/core.py
+++ b/altair/utils/core.py
@@ -546,7 +546,11 @@ def use_signature(Obj):
 
         # Supplement the docstring of f with information from Obj
         if Obj.__doc__:
+            # Patch in a reference to the class this docstring is copied from,
+            # to generate a hyperlink.
             doclines = Obj.__doc__.splitlines()
+            doclines[0] = f"Refer to :class:`{Obj.__name__}`"
+
             if f.__doc__:
                 doc = f.__doc__ + "\n".join(doclines[1:])
             else:
diff --git a/altair/utils/schemapi.py b/altair/utils/schemapi.py
index f2926eda..1e2b0406 100644
--- a/altair/utils/schemapi.py
+++ b/altair/utils/schemapi.py
@@ -6,6 +6,7 @@ import inspect
 import json
 import textwrap
 from typing import Any, Sequence, List
+from itertools import zip_longest
 
 import jsonschema
 import jsonschema.exceptions
@@ -15,6 +16,7 @@ import pandas as pd
 
 from altair import vegalite
 
+
 # If DEBUG_MODE is True, then schema objects are converted to dict and
 # validated at creation time. This slows things down, particularly for
 # larger specs, but leads to much more useful tracebacks for the user.
@@ -158,6 +160,63 @@ class SchemaValidationError(jsonschema.ValidationError):
         self.obj = obj
         self._additional_errors = getattr(err, "_additional_errors", [])
 
+    @staticmethod
+    def _format_params_as_table(param_dict_keys):
+        """Format param names into a table so that they are easier to read"""
+        param_names, name_lengths = zip(
+            *[
+                (name, len(name))
+                for name in param_dict_keys
+                if name not in ["kwds", "self"]
+            ]
+        )
+        # Worst case scenario with the same longest param name in the same
+        # row for all columns
+        max_name_length = max(name_lengths)
+        max_column_width = 80
+        # Output a square table if not too big (since it is easier to read)
+        num_param_names = len(param_names)
+        square_columns = int(np.ceil(num_param_names**0.5))
+        columns = min(max_column_width // max_name_length, square_columns)
+
+        # Compute roughly equal column heights to evenly divide the param names
+        def split_into_equal_parts(n, p):
+            return [n // p + 1] * (n % p) + [n // p] * (p - n % p)
+
+        column_heights = split_into_equal_parts(num_param_names, columns)
+
+        # Section the param names into columns and compute their widths
+        param_names_columns = []
+        column_max_widths = []
+        last_end_idx = 0
+        for ch in column_heights:
+            param_names_columns.append(param_names[last_end_idx : last_end_idx + ch])
+            column_max_widths.append(
+                max([len(param_name) for param_name in param_names_columns[-1]])
+            )
+            last_end_idx = ch + last_end_idx
+
+        # Transpose the param name columns into rows to facilitate looping
+        param_names_rows = []
+        for li in zip_longest(*param_names_columns, fillvalue=""):
+            param_names_rows.append(li)
+        # Build the table as a string by iterating over and formatting the rows
+        param_names_table = ""
+        for param_names_row in param_names_rows:
+            for num, param_name in enumerate(param_names_row):
+                # Set column width based on the longest param in the column
+                max_name_length_column = column_max_widths[num]
+                column_pad = 3
+                param_names_table += "{:<{}}".format(
+                    param_name, max_name_length_column + column_pad
+                )
+                # Insert newlines and spacing after the last element in each row
+                if num == (len(param_names_row) - 1):
+                    param_names_table += "\n"
+                    # 16 is the indendation of the returned multiline string below
+                    param_names_table += " " * 16
+        return param_names_table
+
     def __str__(self):
         # Try to get the lowest class possible in the chart hierarchy so
         # it can be displayed in the error message. This should lead to more informative
@@ -174,26 +233,48 @@ class SchemaValidationError(jsonschema.ValidationError):
             # back on the class of the top-level object which created
             # the SchemaValidationError
             cls = self.obj.__class__
-        schema_path = ["{}.{}".format(cls.__module__, cls.__name__)]
-        schema_path.extend(self.schema_path)
-        schema_path = "->".join(
-            str(val)
-            for val in schema_path[:-1]
-            if val not in (0, "properties", "additionalProperties", "patternProperties")
-        )
-        message = self.message
-        if self._additional_errors:
-            message += "\n        " + "\n        ".join(
-                [e.message for e in self._additional_errors]
-            )
-        return """Invalid specification
 
-        {}, validating {!r}
+        # Output all existing parameters when an unknown parameter is specified
+        if self.validator == "additionalProperties":
+            param_dict_keys = inspect.signature(cls).parameters.keys()
+            param_names_table = self._format_params_as_table(param_dict_keys)
+
+            # `cleandoc` removes multiline string indentation in the output
+            return inspect.cleandoc(
+                """`{}` has no parameter named {!r}
+
+                Existing parameter names are:
+                {}
+                See the help for `{}` to read the full description of these parameters
+                """.format(
+                    cls.__name__,
+                    self.message.split("('")[-1].split("'")[0],
+                    param_names_table,
+                    cls.__name__,
+                )
+            )
+        # Use the default error message for all other cases than unknown parameter errors
+        else:
+            message = self.message
+            # Add a summary line when parameters are passed an invalid value
+            # For example: "'asdf' is an invalid value for `stack`
+            if self.absolute_path:
+                # The indentation here must match that of `cleandoc` below
+                message = f"""'{self.instance}' is an invalid value for `{self.absolute_path[-1]}`:
+
+                {message}"""
+
+            if self._additional_errors:
+                message += "\n                " + "\n                ".join(
+                    [e.message for e in self._additional_errors]
+                )
 
-        {}
-        """.format(
-            schema_path, self.validator, message
-        )
+            return inspect.cleandoc(
+                """{}
+                """.format(
+                    message
+                )
+            )
 
 
 class UndefinedType(object):
diff --git a/altair/vegalite/v5/api.py b/altair/vegalite/v5/api.py
index 81fd1062..456a55a5 100644
--- a/altair/vegalite/v5/api.py
+++ b/altair/vegalite/v5/api.py
@@ -901,7 +901,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         self: _TTopLevelMixin, aggregate=Undefined, groupby=Undefined, **kwds
     ) -> _TTopLevelMixin:
         """
-        Add an AggregateTransform to the schema.
+        Add an :class:`AggregateTransform` to the schema.
 
         Parameters
         ----------
@@ -977,7 +977,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         self: _TTopLevelMixin, as_=Undefined, field=Undefined, bin=True, **kwargs
     ) -> _TTopLevelMixin:
         """
-        Add a BinTransform to the schema.
+        Add a :class:`BinTransform` to the schema.
 
         Parameters
         ----------
@@ -1035,7 +1035,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         self: _TTopLevelMixin, as_=Undefined, calculate=Undefined, **kwargs
     ) -> _TTopLevelMixin:
         """
-        Add a CalculateTransform to the schema.
+        Add a :class:`CalculateTransform` to the schema.
 
         Parameters
         ----------
@@ -1108,7 +1108,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         minsteps=Undefined,
         steps=Undefined,
     ) -> _TTopLevelMixin:
-        """Add a DensityTransform to the spec.
+        """Add a :class:`DensityTransform` to the spec.
 
         Parameters
         ----------
@@ -1174,7 +1174,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         value=Undefined,
     ) -> _TTopLevelMixin:
         """
-        Add an ImputeTransform to the schema.
+        Add an :class:`ImputeTransform` to the schema.
 
         Parameters
         ----------
@@ -1237,7 +1237,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         self: _TTopLevelMixin, joinaggregate=Undefined, groupby=Undefined, **kwargs
     ) -> _TTopLevelMixin:
         """
-        Add a JoinAggregateTransform to the schema.
+        Add a :class:`JoinAggregateTransform` to the schema.
 
         Parameters
         ----------
@@ -1288,7 +1288,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
     # TODO: Update docstring
     def transform_filter(self: _TTopLevelMixin, filter, **kwargs) -> _TTopLevelMixin:
         """
-        Add a FilterTransform to the schema.
+        Add a :class:`FilterTransform` to the schema.
 
         Parameters
         ----------
@@ -1322,7 +1322,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
     def transform_flatten(
         self: _TTopLevelMixin, flatten, as_=Undefined
     ) -> _TTopLevelMixin:
-        """Add a FlattenTransform to the schema.
+        """Add a :class:`FlattenTransform` to the schema.
 
         Parameters
         ----------
@@ -1350,7 +1350,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         )
 
     def transform_fold(self: _TTopLevelMixin, fold, as_=Undefined) -> _TTopLevelMixin:
-        """Add a FoldTransform to the spec.
+        """Add a :class:`FoldTransform` to the spec.
 
         Parameters
         ----------
@@ -1380,7 +1380,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         bandwidth=Undefined,
         groupby=Undefined,
     ) -> _TTopLevelMixin:
-        """Add a LoessTransform to the spec.
+        """Add a :class:`LoessTransform` to the spec.
 
         Parameters
         ----------
@@ -1422,7 +1422,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         default=Undefined,
         **kwargs,
     ) -> _TTopLevelMixin:
-        """Add a DataLookupTransform or SelectionLookupTransform to the chart
+        """Add a :class:`DataLookupTransform` or :class:`SelectionLookupTransform` to the chart
 
         Parameters
         ----------
@@ -1477,7 +1477,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         limit=Undefined,
         op=Undefined,
     ) -> _TTopLevelMixin:
-        """Add a pivot transform to the chart.
+        """Add a :class:`PivotTransform` to the chart.
 
         Parameters
         ----------
@@ -1523,7 +1523,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         probs=Undefined,
         step=Undefined,
     ) -> _TTopLevelMixin:
-        """Add a quantile transform to the chart
+        """Add a :class:`QuantileTransform` to the chart
 
         Parameters
         ----------
@@ -1572,7 +1572,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         order=Undefined,
         params=Undefined,
     ) -> _TTopLevelMixin:
-        """Add a RegressionTransform to the chart.
+        """Add a :class:`RegressionTransform` to the chart.
 
         Parameters
         ----------
@@ -1628,7 +1628,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
 
     def transform_sample(self: _TTopLevelMixin, sample=1000) -> _TTopLevelMixin:
         """
-        Add a SampleTransform to the schema.
+        Add a :class:`SampleTransform` to the schema.
 
         Parameters
         ----------
@@ -1650,7 +1650,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         self: _TTopLevelMixin, as_, stack, groupby, offset=Undefined, sort=Undefined
     ) -> _TTopLevelMixin:
         """
-        Add a StackTransform to the schema.
+        Add a :class:`StackTransform` to the schema.
 
         Parameters
         ----------
@@ -1691,7 +1691,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         **kwargs,
     ) -> _TTopLevelMixin:
         """
-        Add a TimeUnitTransform to the schema.
+        Add a :class:`TimeUnitTransform` to the schema.
 
         Parameters
         ----------
@@ -1776,7 +1776,7 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
         sort=Undefined,
         **kwargs,
     ) -> _TTopLevelMixin:
-        """Add a WindowTransform to the schema
+        """Add a :class:`WindowTransform` to the schema
 
         Parameters
         ----------
diff --git a/altair/vegalite/v5/schema/mixins.py b/altair/vegalite/v5/schema/mixins.py
index 49cdd405..3db7c794 100644
--- a/altair/vegalite/v5/schema/mixins.py
+++ b/altair/vegalite/v5/schema/mixins.py
@@ -36,9 +36,7 @@ class MarkMethodMixin(object):
                  url=Undefined, width=Undefined, x=Undefined, x2=Undefined, x2Offset=Undefined,
                  xOffset=Undefined, y=Undefined, y2=Undefined, y2Offset=Undefined, yOffset=Undefined,
                  **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'arc'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'arc' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -95,9 +93,7 @@ class MarkMethodMixin(object):
                   tooltip=Undefined, url=Undefined, width=Undefined, x=Undefined, x2=Undefined,
                   x2Offset=Undefined, xOffset=Undefined, y=Undefined, y2=Undefined, y2Offset=Undefined,
                   yOffset=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'area'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'area' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -154,9 +150,7 @@ class MarkMethodMixin(object):
                  url=Undefined, width=Undefined, x=Undefined, x2=Undefined, x2Offset=Undefined,
                  xOffset=Undefined, y=Undefined, y2=Undefined, y2Offset=Undefined, yOffset=Undefined,
                  **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'bar'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'bar' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -213,9 +207,7 @@ class MarkMethodMixin(object):
                    timeUnitBandSize=Undefined, tooltip=Undefined, url=Undefined, width=Undefined,
                    x=Undefined, x2=Undefined, x2Offset=Undefined, xOffset=Undefined, y=Undefined,
                    y2=Undefined, y2Offset=Undefined, yOffset=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'image'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'image' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -272,9 +264,7 @@ class MarkMethodMixin(object):
                   tooltip=Undefined, url=Undefined, width=Undefined, x=Undefined, x2=Undefined,
                   x2Offset=Undefined, xOffset=Undefined, y=Undefined, y2=Undefined, y2Offset=Undefined,
                   yOffset=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'line'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'line' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -331,9 +321,7 @@ class MarkMethodMixin(object):
                    timeUnitBandSize=Undefined, tooltip=Undefined, url=Undefined, width=Undefined,
                    x=Undefined, x2=Undefined, x2Offset=Undefined, xOffset=Undefined, y=Undefined,
                    y2=Undefined, y2Offset=Undefined, yOffset=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'point'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'point' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -390,9 +378,7 @@ class MarkMethodMixin(object):
                   tooltip=Undefined, url=Undefined, width=Undefined, x=Undefined, x2=Undefined,
                   x2Offset=Undefined, xOffset=Undefined, y=Undefined, y2=Undefined, y2Offset=Undefined,
                   yOffset=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'rect'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'rect' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -449,9 +435,7 @@ class MarkMethodMixin(object):
                   tooltip=Undefined, url=Undefined, width=Undefined, x=Undefined, x2=Undefined,
                   x2Offset=Undefined, xOffset=Undefined, y=Undefined, y2=Undefined, y2Offset=Undefined,
                   yOffset=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'rule'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'rule' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -508,9 +492,7 @@ class MarkMethodMixin(object):
                   tooltip=Undefined, url=Undefined, width=Undefined, x=Undefined, x2=Undefined,
                   x2Offset=Undefined, xOffset=Undefined, y=Undefined, y2=Undefined, y2Offset=Undefined,
                   yOffset=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'text'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'text' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -567,9 +549,7 @@ class MarkMethodMixin(object):
                   tooltip=Undefined, url=Undefined, width=Undefined, x=Undefined, x2=Undefined,
                   x2Offset=Undefined, xOffset=Undefined, y=Undefined, y2=Undefined, y2Offset=Undefined,
                   yOffset=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'tick'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'tick' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -626,9 +606,7 @@ class MarkMethodMixin(object):
                    timeUnitBandSize=Undefined, tooltip=Undefined, url=Undefined, width=Undefined,
                    x=Undefined, x2=Undefined, x2Offset=Undefined, xOffset=Undefined, y=Undefined,
                    y2=Undefined, y2Offset=Undefined, yOffset=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'trail'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'trail' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -686,9 +664,7 @@ class MarkMethodMixin(object):
                     tooltip=Undefined, url=Undefined, width=Undefined, x=Undefined, x2=Undefined,
                     x2Offset=Undefined, xOffset=Undefined, y=Undefined, y2=Undefined,
                     y2Offset=Undefined, yOffset=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'circle'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'circle' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -746,9 +722,7 @@ class MarkMethodMixin(object):
                     tooltip=Undefined, url=Undefined, width=Undefined, x=Undefined, x2=Undefined,
                     x2Offset=Undefined, xOffset=Undefined, y=Undefined, y2=Undefined,
                     y2Offset=Undefined, yOffset=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'square'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'square' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -806,9 +780,7 @@ class MarkMethodMixin(object):
                       tooltip=Undefined, url=Undefined, width=Undefined, x=Undefined, x2=Undefined,
                       x2Offset=Undefined, xOffset=Undefined, y=Undefined, y2=Undefined,
                       y2Offset=Undefined, yOffset=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'geoshape'
-    
-        For information on additional arguments, see :class:`MarkDef`
+        """Set the chart's mark to 'geoshape' (see :class:`MarkDef`)
         """
         kwds = dict(align=align, angle=angle, aria=aria, ariaRole=ariaRole,
                     ariaRoleDescription=ariaRoleDescription, aspect=aspect, bandSize=bandSize,
@@ -844,9 +816,7 @@ class MarkMethodMixin(object):
     def mark_boxplot(self: _TMarkMethodMixin, box=Undefined, clip=Undefined, color=Undefined,
                      extent=Undefined, median=Undefined, opacity=Undefined, orient=Undefined,
                      outliers=Undefined, rule=Undefined, size=Undefined, ticks=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'boxplot'
-    
-        For information on additional arguments, see :class:`BoxPlotDef`
+        """Set the chart's mark to 'boxplot' (see :class:`BoxPlotDef`)
         """
         kwds = dict(box=box, clip=clip, color=color, extent=extent, median=median, opacity=opacity,
                     orient=orient, outliers=outliers, rule=rule, size=size, ticks=ticks, **kwds)
@@ -860,9 +830,7 @@ class MarkMethodMixin(object):
     def mark_errorbar(self: _TMarkMethodMixin, clip=Undefined, color=Undefined, extent=Undefined,
                       opacity=Undefined, orient=Undefined, rule=Undefined, size=Undefined,
                       thickness=Undefined, ticks=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'errorbar'
-    
-        For information on additional arguments, see :class:`ErrorBarDef`
+        """Set the chart's mark to 'errorbar' (see :class:`ErrorBarDef`)
         """
         kwds = dict(clip=clip, color=color, extent=extent, opacity=opacity, orient=orient, rule=rule,
                     size=size, thickness=thickness, ticks=ticks, **kwds)
@@ -876,9 +844,7 @@ class MarkMethodMixin(object):
     def mark_errorband(self: _TMarkMethodMixin, band=Undefined, borders=Undefined, clip=Undefined,
                        color=Undefined, extent=Undefined, interpolate=Undefined, opacity=Undefined,
                        orient=Undefined, tension=Undefined, **kwds) -> _TMarkMethodMixin:
-        """Set the chart's mark to 'errorband'
-    
-        For information on additional arguments, see :class:`ErrorBandDef`
+        """Set the chart's mark to 'errorband' (see :class:`ErrorBandDef`)
         """
         kwds = dict(band=band, borders=borders, clip=clip, color=color, extent=extent,
                     interpolate=interpolate, opacity=opacity, orient=orient, tension=tension, **kwds)
diff --git a/tools/generate_schema_wrapper.py b/tools/generate_schema_wrapper.py
index aacec676..7942f157 100644
--- a/tools/generate_schema_wrapper.py
+++ b/tools/generate_schema_wrapper.py
@@ -145,9 +145,7 @@ class DatumChannelMixin(object):
 
 MARK_METHOD = '''
 def mark_{mark}({def_arglist}) -> {self_type}:
-    """Set the chart's mark to '{mark}'
-
-    For information on additional arguments, see :class:`{mark_def}`
+    """Set the chart's mark to '{mark}' (see :class:`{mark_def}`)
     """
     kwds = dict({dict_arglist})
     copy = self.copy(deep=False)
diff --git a/tools/schemapi/schemapi.py b/tools/schemapi/schemapi.py
index 3d60fb80..1b84cd24 100644
--- a/tools/schemapi/schemapi.py
+++ b/tools/schemapi/schemapi.py
@@ -4,6 +4,7 @@ import inspect
 import json
 import textwrap
 from typing import Any, Sequence, List
+from itertools import zip_longest
 
 import jsonschema
 import jsonschema.exceptions
@@ -13,6 +14,7 @@ import pandas as pd
 
 from altair import vegalite
 
+
 # If DEBUG_MODE is True, then schema objects are converted to dict and
 # validated at creation time. This slows things down, particularly for
 # larger specs, but leads to much more useful tracebacks for the user.
@@ -156,6 +158,63 @@ class SchemaValidationError(jsonschema.ValidationError):
         self.obj = obj
         self._additional_errors = getattr(err, "_additional_errors", [])
 
+    @staticmethod
+    def _format_params_as_table(param_dict_keys):
+        """Format param names into a table so that they are easier to read"""
+        param_names, name_lengths = zip(
+            *[
+                (name, len(name))
+                for name in param_dict_keys
+                if name not in ["kwds", "self"]
+            ]
+        )
+        # Worst case scenario with the same longest param name in the same
+        # row for all columns
+        max_name_length = max(name_lengths)
+        max_column_width = 80
+        # Output a square table if not too big (since it is easier to read)
+        num_param_names = len(param_names)
+        square_columns = int(np.ceil(num_param_names**0.5))
+        columns = min(max_column_width // max_name_length, square_columns)
+
+        # Compute roughly equal column heights to evenly divide the param names
+        def split_into_equal_parts(n, p):
+            return [n // p + 1] * (n % p) + [n // p] * (p - n % p)
+
+        column_heights = split_into_equal_parts(num_param_names, columns)
+
+        # Section the param names into columns and compute their widths
+        param_names_columns = []
+        column_max_widths = []
+        last_end_idx = 0
+        for ch in column_heights:
+            param_names_columns.append(param_names[last_end_idx : last_end_idx + ch])
+            column_max_widths.append(
+                max([len(param_name) for param_name in param_names_columns[-1]])
+            )
+            last_end_idx = ch + last_end_idx
+
+        # Transpose the param name columns into rows to facilitate looping
+        param_names_rows = []
+        for li in zip_longest(*param_names_columns, fillvalue=""):
+            param_names_rows.append(li)
+        # Build the table as a string by iterating over and formatting the rows
+        param_names_table = ""
+        for param_names_row in param_names_rows:
+            for num, param_name in enumerate(param_names_row):
+                # Set column width based on the longest param in the column
+                max_name_length_column = column_max_widths[num]
+                column_pad = 3
+                param_names_table += "{:<{}}".format(
+                    param_name, max_name_length_column + column_pad
+                )
+                # Insert newlines and spacing after the last element in each row
+                if num == (len(param_names_row) - 1):
+                    param_names_table += "\n"
+                    # 16 is the indendation of the returned multiline string below
+                    param_names_table += " " * 16
+        return param_names_table
+
     def __str__(self):
         # Try to get the lowest class possible in the chart hierarchy so
         # it can be displayed in the error message. This should lead to more informative
@@ -172,26 +231,48 @@ class SchemaValidationError(jsonschema.ValidationError):
             # back on the class of the top-level object which created
             # the SchemaValidationError
             cls = self.obj.__class__
-        schema_path = ["{}.{}".format(cls.__module__, cls.__name__)]
-        schema_path.extend(self.schema_path)
-        schema_path = "->".join(
-            str(val)
-            for val in schema_path[:-1]
-            if val not in (0, "properties", "additionalProperties", "patternProperties")
-        )
-        message = self.message
-        if self._additional_errors:
-            message += "\n        " + "\n        ".join(
-                [e.message for e in self._additional_errors]
-            )
-        return """Invalid specification
 
-        {}, validating {!r}
+        # Output all existing parameters when an unknown parameter is specified
+        if self.validator == "additionalProperties":
+            param_dict_keys = inspect.signature(cls).parameters.keys()
+            param_names_table = self._format_params_as_table(param_dict_keys)
+
+            # `cleandoc` removes multiline string indentation in the output
+            return inspect.cleandoc(
+                """`{}` has no parameter named {!r}
+
+                Existing parameter names are:
+                {}
+                See the help for `{}` to read the full description of these parameters
+                """.format(
+                    cls.__name__,
+                    self.message.split("('")[-1].split("'")[0],
+                    param_names_table,
+                    cls.__name__,
+                )
+            )
+        # Use the default error message for all other cases than unknown parameter errors
+        else:
+            message = self.message
+            # Add a summary line when parameters are passed an invalid value
+            # For example: "'asdf' is an invalid value for `stack`
+            if self.absolute_path:
+                # The indentation here must match that of `cleandoc` below
+                message = f"""'{self.instance}' is an invalid value for `{self.absolute_path[-1]}`:
+
+                {message}"""
+
+            if self._additional_errors:
+                message += "\n                " + "\n                ".join(
+                    [e.message for e in self._additional_errors]
+                )
 
-        {}
-        """.format(
-            schema_path, self.validator, message
-        )
+            return inspect.cleandoc(
+                """{}
+                """.format(
+                    message
+                )
+            )
 
 
 class UndefinedType(object):


Test patch:
diff --git a/tests/utils/tests/test_schemapi.py b/tests/utils/tests/test_schemapi.py
index 9895d57a..9edc7695 100644
--- a/tests/utils/tests/test_schemapi.py
+++ b/tests/utils/tests/test_schemapi.py
@@ -2,6 +2,7 @@
 # tools/generate_schema_wrapper.py. Do not modify directly.
 import copy
 import io
+import inspect
 import json
 import jsonschema
 import re
@@ -377,9 +378,6 @@ def test_schema_validation_error():
     assert isinstance(the_err, SchemaValidationError)
     message = str(the_err)
 
-    assert message.startswith("Invalid specification")
-    assert "test_schemapi.MySchema->a" in message
-    assert "validating {!r}".format(the_err.validator) in message
     assert the_err.message in message
 
 
@@ -467,36 +465,77 @@ def chart_example_invalid_y_option_value_with_condition():
     [
         (
             chart_example_invalid_y_option,
-            r"schema.channels.X.*"
-            + r"Additional properties are not allowed \('unknown' was unexpected\)",
+            inspect.cleandoc(
+                r"""`X` has no parameter named 'unknown'
+
+                Existing parameter names are:
+                shorthand      bin      scale   timeUnit   
+                aggregate      field    sort    title      
+                axis           impute   stack   type       
+                bandPosition                               
+
+                See the help for `X` to read the full description of these parameters"""  # noqa: W291
+            ),
         ),
         (
-            chart_example_invalid_y_option_value,
-            r"schema.channels.Y.*"
-            + r"'asdf' is not one of \['zero', 'center', 'normalize'\].*"
-            + r"'asdf' is not of type 'null'.*'asdf' is not of type 'boolean'",
+            chart_example_layer,
+            inspect.cleandoc(
+                r"""`VConcatChart` has no parameter named 'width'
+
+                Existing parameter names are:
+                vconcat      center     description   params    title       
+                autosize     config     name          resolve   transform   
+                background   data       padding       spacing   usermeta    
+                bounds       datasets                                       
+
+                See the help for `VConcatChart` to read the full description of these parameters"""  # noqa: W291
+            ),
         ),
         (
-            chart_example_layer,
-            r"api.VConcatChart.*"
-            + r"Additional properties are not allowed \('width' was unexpected\)",
+            chart_example_invalid_y_option_value,
+            inspect.cleandoc(
+                r"""'asdf' is an invalid value for `stack`:
+
+                'asdf' is not one of \['zero', 'center', 'normalize'\]
+                'asdf' is not of type 'null'
+                'asdf' is not of type 'boolean'"""
+            ),
         ),
         (
             chart_example_invalid_y_option_value_with_condition,
-            r"schema.channels.Y.*"
-            + r"'asdf' is not one of \['zero', 'center', 'normalize'\].*"
-            + r"'asdf' is not of type 'null'.*'asdf' is not of type 'boolean'",
+            inspect.cleandoc(
+                r"""'asdf' is an invalid value for `stack`:
+
+                'asdf' is not one of \['zero', 'center', 'normalize'\]
+                'asdf' is not of type 'null'
+                'asdf' is not of type 'boolean'"""
+            ),
         ),
         (
             chart_example_hconcat,
-            r"schema.core.TitleParams.*"
-            + r"\{'text': 'Horsepower', 'align': 'right'\} is not of type 'string'.*"
-            + r"\{'text': 'Horsepower', 'align': 'right'\} is not of type 'array'",
+            inspect.cleandoc(
+                r"""'{'text': 'Horsepower', 'align': 'right'}' is an invalid value for `title`:
+
+                {'text': 'Horsepower', 'align': 'right'} is not of type 'string'
+                {'text': 'Horsepower', 'align': 'right'} is not of type 'array'"""
+            ),
         ),
         (
             chart_example_invalid_channel_and_condition,
-            r"schema.core.Encoding->encoding.*"
-            + r"Additional properties are not allowed \('invalidChannel' was unexpected\)",
+            inspect.cleandoc(
+                r"""`Encoding` has no parameter named 'invalidChannel'
+
+                Existing parameter names are:
+                angle         key          order     strokeDash      tooltip   xOffset   
+                color         latitude     radius    strokeOpacity   url       y         
+                description   latitude2    radius2   strokeWidth     x         y2        
+                detail        longitude    shape     text            x2        yError    
+                fill          longitude2   size      theta           xError    yError2   
+                fillOpacity   opacity      stroke    theta2          xError2   yOffset   
+                href                                                                     
+
+                See the help for `Encoding` to read the full description of these parameters"""  # noqa: W291
+            ),
         ),
     ],
 )
diff --git a/tools/schemapi/tests/test_schemapi.py b/tools/schemapi/tests/test_schemapi.py
index f8ad19c9..bb3bbae7 100644
--- a/tools/schemapi/tests/test_schemapi.py
+++ b/tools/schemapi/tests/test_schemapi.py
@@ -1,5 +1,6 @@
 import copy
 import io
+import inspect
 import json
 import jsonschema
 import re
@@ -375,9 +376,6 @@ def test_schema_validation_error():
     assert isinstance(the_err, SchemaValidationError)
     message = str(the_err)
 
-    assert message.startswith("Invalid specification")
-    assert "test_schemapi.MySchema->a" in message
-    assert "validating {!r}".format(the_err.validator) in message
     assert the_err.message in message
 
 
@@ -465,36 +463,77 @@ def chart_example_invalid_y_option_value_with_condition():
     [
         (
             chart_example_invalid_y_option,
-            r"schema.channels.X.*"
-            + r"Additional properties are not allowed \('unknown' was unexpected\)",
+            inspect.cleandoc(
+                r"""`X` has no parameter named 'unknown'
+
+                Existing parameter names are:
+                shorthand      bin      scale   timeUnit   
+                aggregate      field    sort    title      
+                axis           impute   stack   type       
+                bandPosition                               
+
+                See the help for `X` to read the full description of these parameters"""  # noqa: W291
+            ),
         ),
         (
-            chart_example_invalid_y_option_value,
-            r"schema.channels.Y.*"
-            + r"'asdf' is not one of \['zero', 'center', 'normalize'\].*"
-            + r"'asdf' is not of type 'null'.*'asdf' is not of type 'boolean'",
+            chart_example_layer,
+            inspect.cleandoc(
+                r"""`VConcatChart` has no parameter named 'width'
+
+                Existing parameter names are:
+                vconcat      center     description   params    title       
+                autosize     config     name          resolve   transform   
+                background   data       padding       spacing   usermeta    
+                bounds       datasets                                       
+
+                See the help for `VConcatChart` to read the full description of these parameters"""  # noqa: W291
+            ),
         ),
         (
-            chart_example_layer,
-            r"api.VConcatChart.*"
-            + r"Additional properties are not allowed \('width' was unexpected\)",
+            chart_example_invalid_y_option_value,
+            inspect.cleandoc(
+                r"""'asdf' is an invalid value for `stack`:
+
+                'asdf' is not one of \['zero', 'center', 'normalize'\]
+                'asdf' is not of type 'null'
+                'asdf' is not of type 'boolean'"""
+            ),
         ),
         (
             chart_example_invalid_y_option_value_with_condition,
-            r"schema.channels.Y.*"
-            + r"'asdf' is not one of \['zero', 'center', 'normalize'\].*"
-            + r"'asdf' is not of type 'null'.*'asdf' is not of type 'boolean'",
+            inspect.cleandoc(
+                r"""'asdf' is an invalid value for `stack`:
+
+                'asdf' is not one of \['zero', 'center', 'normalize'\]
+                'asdf' is not of type 'null'
+                'asdf' is not of type 'boolean'"""
+            ),
         ),
         (
             chart_example_hconcat,
-            r"schema.core.TitleParams.*"
-            + r"\{'text': 'Horsepower', 'align': 'right'\} is not of type 'string'.*"
-            + r"\{'text': 'Horsepower', 'align': 'right'\} is not of type 'array'",
+            inspect.cleandoc(
+                r"""'{'text': 'Horsepower', 'align': 'right'}' is an invalid value for `title`:
+
+                {'text': 'Horsepower', 'align': 'right'} is not of type 'string'
+                {'text': 'Horsepower', 'align': 'right'} is not of type 'array'"""
+            ),
         ),
         (
             chart_example_invalid_channel_and_condition,
-            r"schema.core.Encoding->encoding.*"
-            + r"Additional properties are not allowed \('invalidChannel' was unexpected\)",
+            inspect.cleandoc(
+                r"""`Encoding` has no parameter named 'invalidChannel'
+
+                Existing parameter names are:
+                angle         key          order     strokeDash      tooltip   xOffset   
+                color         latitude     radius    strokeOpacity   url       y         
+                description   latitude2    radius2   strokeWidth     x         y2        
+                detail        longitude    shape     text            x2        yError    
+                fill          longitude2   size      theta           xError    yError2   
+                fillOpacity   opacity      stroke    theta2          xError2   yOffset   
+                href                                                                     
+
+                See the help for `Encoding` to read the full description of these parameters"""  # noqa: W291
+            ),
         ),
     ],
 )