Repository: altair-viz/altair

Issue:
Chart representation changes after the chart is displayed
The representation of the chart object seems to change after the chart is displayed. Take this example

```py
import altair as alt
from vega_datasets import data


cars = data.cars()

scatter = alt.Chart(cars).mark_point().encode(
    x='Weight_in_lbs:Q',
    y='Horsepower',
    color='Origin')
```

It looks like this initially 

```py
scatter.encoding
```

```py
FacetedEncoding({
  color: Color({
    shorthand: 'Origin'
  }),
  x: X({
    shorthand: 'Weight_in_lbs:Q'
  }),
  y: Y({
    shorthand: 'Horsepower'
  })
})
```

But after displaying the chart in JuptyerLab (by just typing `scatter` in a cell and executing it), the encoding representation changes to this longer format:

```py
FacetedEncoding({
  color: Color({
    field: 'Origin',
    type: 'nominal'
  }),
  x: X({
    field: 'Weight_in_lbs',
    type: 'quantitative'
  }),
  y: Y({
    field: 'Horsepower',
    type: 'quantitative'
  })
})
```

There is probably some good reason for this that I don't understand, but for testing/autograding purposes it would be convenient if the representation was always the same and did not change after the chart is displayed.

Edit: It seems like `scatter.to_dict()` will always return the long format so maybe that could be relied on instead.

Discussion:
binste: Sure! Done :)

Patch:
diff --git a/altair/utils/schemapi.py b/altair/utils/schemapi.py
index b94017c9..4471c65c 100644
--- a/altair/utils/schemapi.py
+++ b/altair/utils/schemapi.py
@@ -358,8 +358,24 @@ class SchemaBase(object):
         if self._args and not self._kwds:
             result = _todict(self._args[0], validate=sub_validate, context=context)
         elif not self._args:
+            kwds = self._kwds.copy()
+            # parsed_shorthand is added by FieldChannelMixin.
+            # It's used below to replace shorthand with its long form equivalent
+            # parsed_shorthand is removed from context if it exists so that it is
+            # not passed to child to_dict function calls
+            parsed_shorthand = context.pop("parsed_shorthand", {})
+            kwds.update(
+                {
+                    k: v
+                    for k, v in parsed_shorthand.items()
+                    if kwds.get(k, Undefined) is Undefined
+                }
+            )
+            kwds = {
+                k: v for k, v in kwds.items() if k not in list(ignore) + ["shorthand"]
+            }
             result = _todict(
-                {k: v for k, v in self._kwds.items() if k not in ignore},
+                kwds,
                 validate=sub_validate,
                 context=context,
             )
diff --git a/altair/vegalite/v5/schema/channels.py b/altair/vegalite/v5/schema/channels.py
index 714dd9a4..bb078f59 100644
--- a/altair/vegalite/v5/schema/channels.py
+++ b/altair/vegalite/v5/schema/channels.py
@@ -50,11 +50,8 @@ class FieldChannelMixin(object):
             # Shorthand is not a string; we pass the definition to field,
             # and do not do any parsing.
             parsed = {'field': shorthand}
+        context["parsed_shorthand"] = parsed
 
-        # Set shorthand to Undefined, because it's not part of the base schema.
-        self.shorthand = Undefined
-        self._kwds.update({k: v for k, v in parsed.items()
-                           if self._get(k) is Undefined})
         return super(FieldChannelMixin, self).to_dict(
             validate=validate,
             ignore=ignore,
diff --git a/tools/generate_schema_wrapper.py b/tools/generate_schema_wrapper.py
index a073457f..d2483849 100644
--- a/tools/generate_schema_wrapper.py
+++ b/tools/generate_schema_wrapper.py
@@ -132,11 +132,8 @@ class FieldChannelMixin(object):
             # Shorthand is not a string; we pass the definition to field,
             # and do not do any parsing.
             parsed = {'field': shorthand}
+        context["parsed_shorthand"] = parsed
 
-        # Set shorthand to Undefined, because it's not part of the base schema.
-        self.shorthand = Undefined
-        self._kwds.update({k: v for k, v in parsed.items()
-                           if self._get(k) is Undefined})
         return super(FieldChannelMixin, self).to_dict(
             validate=validate,
             ignore=ignore,
diff --git a/tools/schemapi/schemapi.py b/tools/schemapi/schemapi.py
index 73e530fc..5520592c 100644
--- a/tools/schemapi/schemapi.py
+++ b/tools/schemapi/schemapi.py
@@ -356,8 +356,24 @@ class SchemaBase(object):
         if self._args and not self._kwds:
             result = _todict(self._args[0], validate=sub_validate, context=context)
         elif not self._args:
+            kwds = self._kwds.copy()
+            # parsed_shorthand is added by FieldChannelMixin.
+            # It's used below to replace shorthand with its long form equivalent
+            # parsed_shorthand is removed from context if it exists so that it is
+            # not passed to child to_dict function calls
+            parsed_shorthand = context.pop("parsed_shorthand", {})
+            kwds.update(
+                {
+                    k: v
+                    for k, v in parsed_shorthand.items()
+                    if kwds.get(k, Undefined) is Undefined
+                }
+            )
+            kwds = {
+                k: v for k, v in kwds.items() if k not in list(ignore) + ["shorthand"]
+            }
             result = _todict(
-                {k: v for k, v in self._kwds.items() if k not in ignore},
+                kwds,
                 validate=sub_validate,
                 context=context,
             )


Test patch:
diff --git a/tests/utils/tests/test_schemapi.py b/tests/utils/tests/test_schemapi.py
index b83ac67c..4c82839b 100644
--- a/tests/utils/tests/test_schemapi.py
+++ b/tests/utils/tests/test_schemapi.py
@@ -8,7 +8,9 @@ import pickle
 import pytest
 
 import numpy as np
+import pandas as pd
 
+import altair as alt
 from altair import load_schema
 from altair.utils.schemapi import (
     UndefinedType,
@@ -391,3 +393,36 @@ def test_serialize_numpy_types():
         "a2": {"int64": 1, "float64": 2},
         "b2": [0, 1, 2, 3],
     }
+
+
+def test_to_dict_no_side_effects():
+    # Tests that shorthands are expanded in returned dictionary when calling to_dict
+    # but that they remain untouched in the chart object. Goal is to ensure that
+    # the chart object stays unchanged when to_dict is called
+    def validate_encoding(encoding):
+        assert encoding.x["shorthand"] == "a"
+        assert encoding.x["field"] is alt.Undefined
+        assert encoding.x["type"] is alt.Undefined
+        assert encoding.y["shorthand"] == "b:Q"
+        assert encoding.y["field"] is alt.Undefined
+        assert encoding.y["type"] is alt.Undefined
+
+    data = pd.DataFrame(
+        {
+            "a": ["A", "B", "C", "D", "E", "F", "G", "H", "I"],
+            "b": [28, 55, 43, 91, 81, 53, 19, 87, 52],
+        }
+    )
+
+    chart = alt.Chart(data).mark_bar().encode(x="a", y="b:Q")
+
+    validate_encoding(chart.encoding)
+    dct = chart.to_dict()
+    validate_encoding(chart.encoding)
+
+    assert "shorthand" not in dct["encoding"]["x"]
+    assert dct["encoding"]["x"]["field"] == "a"
+
+    assert "shorthand" not in dct["encoding"]["y"]
+    assert dct["encoding"]["y"]["field"] == "b"
+    assert dct["encoding"]["y"]["type"] == "quantitative"
diff --git a/tools/schemapi/tests/test_schemapi.py b/tools/schemapi/tests/test_schemapi.py
index 40b9b971..f5e7469e 100644
--- a/tools/schemapi/tests/test_schemapi.py
+++ b/tools/schemapi/tests/test_schemapi.py
@@ -6,7 +6,9 @@ import pickle
 import pytest
 
 import numpy as np
+import pandas as pd
 
+import altair as alt
 from altair import load_schema
 from altair.utils.schemapi import (
     UndefinedType,
@@ -389,3 +391,36 @@ def test_serialize_numpy_types():
         "a2": {"int64": 1, "float64": 2},
         "b2": [0, 1, 2, 3],
     }
+
+
+def test_to_dict_no_side_effects():
+    # Tests that shorthands are expanded in returned dictionary when calling to_dict
+    # but that they remain untouched in the chart object. Goal is to ensure that
+    # the chart object stays unchanged when to_dict is called
+    def validate_encoding(encoding):
+        assert encoding.x["shorthand"] == "a"
+        assert encoding.x["field"] is alt.Undefined
+        assert encoding.x["type"] is alt.Undefined
+        assert encoding.y["shorthand"] == "b:Q"
+        assert encoding.y["field"] is alt.Undefined
+        assert encoding.y["type"] is alt.Undefined
+
+    data = pd.DataFrame(
+        {
+            "a": ["A", "B", "C", "D", "E", "F", "G", "H", "I"],
+            "b": [28, 55, 43, 91, 81, 53, 19, 87, 52],
+        }
+    )
+
+    chart = alt.Chart(data).mark_bar().encode(x="a", y="b:Q")
+
+    validate_encoding(chart.encoding)
+    dct = chart.to_dict()
+    validate_encoding(chart.encoding)
+
+    assert "shorthand" not in dct["encoding"]["x"]
+    assert dct["encoding"]["x"]["field"] == "a"
+
+    assert "shorthand" not in dct["encoding"]["y"]
+    assert dct["encoding"]["y"]["field"] == "b"
+    assert dct["encoding"]["y"]["type"] == "quantitative"