Repository: vega/altair
Pull request: fix: Raise informative error message if a non-existent column name is passed
closes #3532

Problem statement:
regression: unclear error message when plotting non-existent column name
### What happened?

I ran
```python
import pandas as pd
import altair as alt

df = pd.DataFrame({'a':[1,2], 'b':[4,5]})
alt.Chart(df).mark_line().encode(x='a', y='c').to_json()
```

✅ On Altair 5.3.0, I get
```
ValueError: Unable to determine data type for the field "c"; verify that the field name is not misspelled. If you are referencing a field from a transform, also confirm that the data type is specified correctly.
```

🔴 On Altair 5.4.0, I get
```
ValueError: c encoding field is specified without a type; the type cannot be automatically inferred because the data is not specified as a pandas.DataFrame.
```

### What would you like to happen instead?

Keep the 5.3.0 error

This one's my fault from the Narwhalification, sorry about that - submitting a fix now

### Which version of Altair are you using?

5.4.0

Hints from issue discussion:

Commit message: test: Confirm `(pd|pl)` parity in `test_non_existent_column_name`

Just to be extra cautious, since the original message in https://github.com/vega/altair/issues/3532 referenced `pandas` directly

Patch to review:
diff --git a/altair/vegalite/v5/schema/channels.py b/altair/vegalite/v5/schema/channels.py
index 34daf00ab..bf0008e9b 100644
--- a/altair/vegalite/v5/schema/channels.py
+++ b/altair/vegalite/v5/schema/channels.py
@@ -14,7 +14,7 @@
 from typing import TYPE_CHECKING, Any, Literal, Sequence, TypedDict, Union, overload
 from typing_extensions import TypeAlias
 
-from narwhals.dependencies import is_pandas_dataframe as _is_pandas_dataframe
+import narwhals.stable.v1 as nw
 
 from altair.utils import infer_encoding_types as _infer_encoding_types
 from altair.utils import parse_shorthand
@@ -179,7 +179,7 @@ def to_dict(
                 # We still parse it out of the shorthand, but drop it here.
                 parsed.pop("type", None)
             elif not (type_in_shorthand or type_defined_explicitly):
-                if _is_pandas_dataframe(context.get("data", None)):
+                if isinstance(context.get("data", None), nw.DataFrame):
                     msg = (
                         f'Unable to determine data type for the field "{shorthand}";'
                         " verify that the field name is not misspelled."
diff --git a/tests/utils/test_schemapi.py b/tests/utils/test_schemapi.py
index acea9dcb6..a6107601a 100644
--- a/tests/utils/test_schemapi.py
+++ b/tests/utils/test_schemapi.py
@@ -10,7 +10,7 @@
 import warnings
 from collections import deque
 from functools import partial
-from typing import Any, Callable, Iterable, Sequence
+from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence
 
 import jsonschema
 import jsonschema.exceptions
@@ -33,6 +33,9 @@
 from altair.vegalite.v5.schema.core import FieldOneOfPredicate, Legend
 from vega_datasets import data
 
+if TYPE_CHECKING:
+    from narwhals.typing import IntoDataFrame
+
 _JSON_SCHEMA_DRAFT_URL = load_schema()["$schema"]
 # Make tests inherit from _TestSchema, so that when we test from_dict it won't
 # try to use SchemaBase objects defined elsewhere as wrappers.
@@ -881,6 +884,18 @@ def test_multiple_field_strings_in_condition():
         )
 
 
+@pytest.mark.parametrize("tp", [pd.DataFrame, pl.DataFrame])
+def test_non_existent_column_name(tp: Callable[..., IntoDataFrame]) -> None:
+    df = tp({"a": [1, 2], "b": [4, 5]})
+    msg = (
+        'Unable to determine data type for the field "c"; verify that the field name '
+        "is not misspelled. If you are referencing a field from a transform, also "
+        "confirm that the data type is specified correctly."
+    )
+    with pytest.raises(ValueError, match=msg):
+        alt.Chart(df).mark_line().encode(x="a", y="c").to_json()
+
+
 def test_serialize_numpy_types():
     m = MySchema(
         a={"date": np.datetime64("2019-01-01")},
diff --git a/tools/generate_schema_wrapper.py b/tools/generate_schema_wrapper.py
index e73902da3..1e78942de 100644
--- a/tools/generate_schema_wrapper.py
+++ b/tools/generate_schema_wrapper.py
@@ -113,7 +113,7 @@ def to_dict(
                 # We still parse it out of the shorthand, but drop it here.
                 parsed.pop("type", None)
             elif not (type_in_shorthand or type_defined_explicitly):
-                if _is_pandas_dataframe(context.get("data", None)):
+                if isinstance(context.get("data", None), nw.DataFrame):
                     msg = (
                         f'Unable to determine data type for the field "{shorthand}";'
                         " verify that the field name is not misspelled."
@@ -677,7 +677,7 @@ def generate_vegalite_channel_wrappers(
         "from __future__ import annotations\n",
         "from typing import Any, overload, Sequence, List, Literal, Union, TYPE_CHECKING, TypedDict",
         "from typing_extensions import TypeAlias",
-        "from narwhals.dependencies import is_pandas_dataframe as _is_pandas_dataframe",
+        "import narwhals.stable.v1 as nw",
         "from altair.utils.schemapi import Undefined, with_property_setters",
         "from altair.utils import infer_encoding_types as _infer_encoding_types",
         "from altair.utils import parse_shorthand",


Code review comments:
diff_hunk: @@ -179,7 +179,7 @@ def to_dict(
                 # We still parse it out of the shorthand, but drop it here.
                 parsed.pop("type", None)
             elif not (type_in_shorthand or type_defined_explicitly):
-                if _is_pandas_dataframe(context.get("data", None)):
+                if isinstance(context.get("data", None), nw.DataFrame):
path: altair/vegalite/v5/schema/channels.py
text: @user1:
@author I can't seem to be able to suggest on the right line, but what do you think of one line prior to https://github.com/MarcoGorelli/altair/blob/d9e219bc15d4155e3fb1a1d4dd8b27544a267f8f/altair/vegalite/v5/schema/channels.py#L173

```py
# Whatever the type you're expected, maybe nw.NativeFrame?
# I'm just using a placeholder `SomeFrame`
data: SomeFrame | None = context.get("data", None)
parsed = parse_shorthand(shorthand, data=data)
...
if not type_required:
    ...
elif not (type_in_shorthand or type_defined_explicitly):
    if isinstance(data, nw.DataFrame):
        ...
...
```

I'm thinking it would hint towards the expectation that it has been "Narwhalified"