Repository: vega/altair

Issue:
JSON to Chart failure in v5.0.0
As part of a larger application, we're moving chart content around in JSON format via `Chart.to_json` and `Chart.from_json`. However, I'm encountering an error after updating to Altair v5.0.0. An example that used to work previously - based on the layered chart in https://altair-viz.github.io/altair-tutorial/notebooks/09-Geographic-plots.html

```python
import altair as alt
from vega_datasets import data

states = alt.topo_feature(data.us_10m.url, feature='states')
airports = data.airports()

background = alt.Chart(states).mark_geoshape(
    fill='lightgray',
    stroke='white'
).project('albersUsa').properties(
    width=500,
    height=300
)

points = alt.Chart(airports).mark_circle().encode(
    longitude='longitude:Q',
    latitude='latitude:Q',
    size=alt.value(10),
    tooltip='name'
)

chart = background + points

json = chart.to_json()
chart = alt.Chart.from_json(json)
```

In v4.2.2 this runs without any errors. In v5.0.0 this results in

```
Traceback (most recent call last):
  File "altair_scratchpad.py", line 25, in <module>
    chart = alt.Chart.from_json(json)
  File "C:\Users\romesh\miniconda3\envs\atomica38\lib\site-packages\altair\utils\schemapi.py", line 914, in from_json
    return cls.from_dict(dct, validate=validate)
  File "C:\Users\romesh\miniconda3\envs\atomica38\lib\site-packages\altair\vegalite\v5\api.py", line 2497, in from_dict
    return class_.from_dict(dct, validate=validate)  # type: ignore[attr-defined]
  File "C:\Users\romesh\miniconda3\envs\atomica38\lib\site-packages\altair\utils\schemapi.py", line 893, in from_dict
    return converter.from_dict(dct, cls)
  File "C:\Users\romesh\miniconda3\envs\atomica38\lib\site-packages\altair\utils\schemapi.py", line 1058, in from_dict
    return cls(**kwds)
  File "C:\Users\romesh\miniconda3\envs\atomica38\lib\site-packages\altair\vegalite\v5\api.py", line 2982, in __init__
    combined_dict, self.layer = _remove_layer_props(self, self.layer, layer_props)
  File "C:\Users\romesh\miniconda3\envs\atomica38\lib\site-packages\altair\vegalite\v5\api.py", line 3393, in _remove_layer_props
    if all(c[prop] is Undefined or c[prop] == chart[prop] for c in subcharts):
  File "C:\Users\romesh\miniconda3\envs\atomica38\lib\site-packages\altair\vegalite\v5\api.py", line 3393, in <genexpr>
    if all(c[prop] is Undefined or c[prop] == chart[prop] for c in subcharts):
  File "C:\Users\romesh\miniconda3\envs\atomica38\lib\site-packages\altair\utils\schemapi.py", line 711, in __getitem__
    return self._kwds[item]
KeyError: 'height'
```



Discussion:
Thank you @RomeshA for reporting this! I can replicate the error and will look into it in the coming days. I'll also consider using the example gallery for automated tests for the `from_json` functionality to prevent future regressions.

Resolving patch:
diff --git a/altair/vegalite/v5/api.py b/altair/vegalite/v5/api.py
--- a/altair/vegalite/v5/api.py
+++ b/altair/vegalite/v5/api.py
@@ -3395,9 +3395,12 @@ def remove_prop(subchart, prop):
             else:
                 raise ValueError(f"There are inconsistent values {values} for {prop}")
         else:
-            # Top level has this prop; subchart props must be either
-            # Undefined or identical to proceed.
-            if all(c[prop] is Undefined or c[prop] == chart[prop] for c in subcharts):
+            # Top level has this prop; subchart must either not have the prop
+            # or it must be Undefined or identical to proceed.
+            if all(
+                getattr(c, prop, Undefined) is Undefined or c[prop] == chart[prop]
+                for c in subcharts
+            ):
                 output_dict[prop] = chart[prop]
             else:
                 raise ValueError(f"There are inconsistent values {values} for {prop}")


Test patch:
diff --git a/tests/test_examples.py b/tests/test_examples.py
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -3,6 +3,7 @@
 
 import pytest
 
+import altair as alt
 from altair.utils.execeval import eval_block
 from tests import examples_arguments_syntax
 from tests import examples_methods_syntax
@@ -48,6 +49,39 @@ def test_render_examples_to_chart(syntax_module):
             ) from err
 
 
+@pytest.mark.parametrize(
+    "syntax_module", [examples_arguments_syntax, examples_methods_syntax]
+)
+def test_from_and_to_json_roundtrip(syntax_module):
+    """Tests if the to_json and from_json (and by extension to_dict and from_dict)
+    work for all examples in the Example Gallery.
+    """
+    for filename in iter_examples_filenames(syntax_module):
+        source = pkgutil.get_data(syntax_module.__name__, filename)
+        chart = eval_block(source)
+
+        if chart is None:
+            raise ValueError(
+                f"Example file {filename} should define chart in its final "
+                "statement."
+            )
+
+        try:
+            first_json = chart.to_json()
+            reconstructed_chart = alt.Chart.from_json(first_json)
+            # As the chart objects are not
+            # necessarily the same - they could use different objects to encode the same
+            # information - we do not test for equality of the chart objects, but rather
+            # for equality of the json strings.
+            second_json = reconstructed_chart.to_json()
+            assert first_json == second_json
+        except Exception as err:
+            raise AssertionError(
+                f"Example file {filename} raised an exception when "
+                f"doing a json conversion roundtrip: {err}"
+            ) from err
+
+
 # We do not apply the save_engine mark to this test. This mark is used in
 # the build GitHub Action workflow to select the tests which should be rerun
 # with some of the saving engines uninstalled. This would not make sense for this test