Repository: mwaskom/seaborn

Issue:
seaborn.object: `TypeError: `x` given by both name and position` raised by polars and not by pandas
I am plotting a simple histogram from the result of a `.value_counts()`, so I just have two columns (see collapsible element)

```
┌───────────────────────────────────┬────────┐
│ source                            ┆ counts │
│ ---                               ┆ ---    │
│ str                               ┆ u32    │
╞═══════════════════════════════════╪════════╡
│ Professioni sanitarie - Milano (… ┆ 40     │
│ Veterinaria - 2003                ┆ 25     │
│ Veterinaria - 2002                ┆ 31     │
│ Odontoiatria - 2010               ┆ 40     │
│ Professioni sanitarie - Bologna … ┆ 28     │
└───────────────────────────────────┴────────┘
```

The code I use for plotting is:

```python
(
    prod.select(pl.col("content").struct.field("source").value_counts())
    .unnest("source")
    .pipe(so.Plot, x="source", y="counts")
    .add(so.Bar())
)
```

But this error is raised: `TypeError: 'x' given by both name and position.`

<details>
<summary><i>Full traceback</i></summary>

```
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[30], line 2
      1 (
----> 2     prod.select(pl.col("content").struct.field("source").value_counts())
      3     .unnest("source")
      4     # .to_pandas()
      5     .pipe(so.Plot, x="source", y="counts")
      6     .add(so.Bar())
      7 )

File [~/Documents/dev/futura/futura-grader/.venv/lib/python3.9/site-packages/polars/dataframe/frame.py:4448](https://file+.vscode-resource.vscode-cdn.net/Users/lucabaggi/Documents/dev/futura/futura-grader/notebooks/~/Documents/dev/futura/futura-grader/.venv/lib/python3.9/site-packages/polars/dataframe/frame.py:4448), in DataFrame.pipe(self, function, *args, **kwargs)
   4381 def pipe(
   4382     self,
   4383     function: Callable[Concatenate[DataFrame, P], T],
   4384     *args: P.args,
   4385     **kwargs: P.kwargs,
   4386 ) -> T:
   4387     """
   4388     Offers a structured way to apply a sequence of user-defined functions (UDFs).
   4389 
   (...)
   4446 
   4447     """
-> 4448     return function(self, *args, **kwargs)

File [~/Documents/dev/futura/futura-grader/.venv/lib/python3.9/site-packages/seaborn/_core/plot.py:208](https://file+.vscode-resource.vscode-cdn.net/Users/lucabaggi/Documents/dev/futura/futura-grader/notebooks/~/Documents/dev/futura/futura-grader/.venv/lib/python3.9/site-packages/seaborn/_core/plot.py:208), in Plot.__init__(self, data, *args, **variables)
    200 def __init__(
    201     self,
    202     *args: DataSource | VariableSpec,
    203     data: DataSource = None,
    204     **variables: VariableSpec,
    205 ):
    207     if args:
--> 208         data, variables = self._resolve_positionals(args, data, variables)
    210     unknown = [x for x in variables if x not in PROPERTIES]
    211     if unknown:

File [~/Documents/dev/futura/futura-grader/.venv/lib/python3.9/site-packages/seaborn/_core/plot.py:262](https://file+.vscode-resource.vscode-cdn.net/Users/lucabaggi/Documents/dev/futura/futura-grader/notebooks/~/Documents/dev/futura/futura-grader/.venv/lib/python3.9/site-packages/seaborn/_core/plot.py:262), in Plot._resolve_positionals(self, args, data, variables)
    260 if var is not None:
    261     if name in variables:
--> 262         raise TypeError(f"`{name}` given by both name and position.")
    263     # Keep coordinates at the front of the variables dict
    264     # Cast type because we know this isn't a DataSource at this point
    265     variables = {name: cast(VariableSpec, var), **variables}

TypeError: `x` given by both name and position.
```
</details>

However, when I transform to pandas, the plot is drawn correctly.

```python
(
    prod.select(pl.col("content").struct.field("source").value_counts())
    .unnest("source")
    .to_pandas()
    .pipe(so.Plot, x="source", y="counts")
    .add(so.Bar())
)
```

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/mwaskom/seaborn/pull/3369?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) Report
> Merging [#3369](https://app.codecov.io/gh/mwaskom/seaborn/pull/3369?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) (ba32db8) into [master](https://app.codecov.io/gh/mwaskom/seaborn/commit/129ce703052ff56f27014a0d8e4e6203b531b7a8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) (129ce70) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/mwaskom/seaborn/pull/3369/graphs/tree.svg?width=650&height=150&src=pr&token=SLuzHnA9Bl&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom)](https://app.codecov.io/gh/mwaskom/seaborn/pull/3369?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom)

```diff
@@           Coverage Diff           @@
##           master    #3369   +/-   ##
=======================================
  Coverage   98.24%   98.25%           
=======================================
  Files          77       77           
  Lines       24791    24841   +50     
=======================================
+ Hits        24357    24407   +50     
  Misses        434      434           
```


| [Impacted Files](https://app.codecov.io/gh/mwaskom/seaborn/pull/3369?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) | Coverage Δ | |
|---|---|---|
| [seaborn/\_core/data.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3369?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9fY29yZS9kYXRhLnB5) | `99.07% <100.00%> (+0.16%)` | :arrow_up: |
| [seaborn/\_core/plot.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3369?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9fY29yZS9wbG90LnB5) | `99.38% <100.00%> (ø)` | |
| [seaborn/\_core/typing.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3369?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9fY29yZS90eXBpbmcucHk=) | `100.00% <100.00%> (ø)` | |
| [tests/\_core/test\_data.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3369?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-dGVzdHMvX2NvcmUvdGVzdF9kYXRhLnB5) | `100.00% <100.00%> (ø)` | |
| [tests/\_core/test\_plot.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3369?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-dGVzdHMvX2NvcmUvdGVzdF9wbG90LnB5) | `98.80% <100.00%> (+<0.01%)` | :arrow_up: |
| [tests/conftest.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3369?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-dGVzdHMvY29uZnRlc3QucHk=) | `100.00% <100.00%> (ø)` | |

</details>
MarcoGorelli: thanks for the ping

I tried this out, but got a failure for the `doc/_docstrings/FacetGrid.ipynb` notebook:
```python
tips = pl.from_pandas(sns.load_dataset('tips'))
g = sns.FacetGrid(tips, col="time",  row="sex")
g.map(sns.scatterplot, "total_bill", "tip")
```

```python-traceback
SchemaError                               Traceback (most recent call last)
Cell In[4], line 2
      1 g = sns.FacetGrid(tips, col="time",  row="sex")
----> 2 g.map(sns.scatterplot, "total_bill", "tip")

File ~/seaborn-dev/seaborn/axisgrid.py:720, in FacetGrid.map(self, func, *args, **kwargs)
    717         warnings.warn(warning)
    719 # Iterate over the data subsets
--> 720 for (row_i, col_j, hue_k), data_ijk in self.facet_data():
    721 
    722     # If this subset is null, move on
    723     if not data_ijk.values.size:
    724         continue

File ~/seaborn-dev/seaborn/axisgrid.py:674, in FacetGrid.facet_data(self)
    670 # Here is the main generator loop
    671 for (i, row), (j, col), (k, hue) in product(enumerate(row_masks),
    672                                             enumerate(col_masks),
    673                                             enumerate(hue_masks)):
--> 674     data_ijk = data[row & col & hue & self._not_na]
    675     yield (i, j, k), data_ijk

File ~/seaborn-dev/.venv/lib/python3.10/site-packages/polars/series/series.py:439, in Series.__and__(self, other)
    437 if not isinstance(other, Series):
    438     other = Series([other])
--> 439 return self._from_pyseries(self._s.bitand(other._s))

SchemaError: cannot unpack series of type `list[bool]` into `bool`
```
mwaskom: > I tried this out, but got a failure for the doc/_docstrings/FacetGrid.ipynb notebook

Right, this just addresses the [objects interface](https://seaborn.pydata.org/tutorial/objects_interface.html); the older code will need to be handled separately.

MarcoGorelli: I see, thanks!

I tried
```
git ls-files doc/_docstrings/objects*.ipynb | xargs jupyter nbconvert --to notebook --execute --inplace
git ls-files doc/_tutorial/objects_interface.ipynb | xargs jupyter nbconvert --to notebook --execute --inplace
```
and both commands pass without errors (in my `demo-support-interchange-protocol-2`, where I make `load_dataset` return a polars DataFrame)

Patch:
diff --git a/seaborn/_base.py b/seaborn/_base.py
index 8b890252..a3861aed 100644
--- a/seaborn/_base.py
+++ b/seaborn/_base.py
@@ -11,14 +11,15 @@ import numpy as np
 import pandas as pd
 import matplotlib as mpl
 
-from ._decorators import (
+from seaborn._core.data import PlotData
+from seaborn._decorators import (
     share_init_params_with_map,
 )
-from .palettes import (
+from seaborn.palettes import (
     QUAL_PALETTES,
     color_palette,
 )
-from .utils import (
+from seaborn.utils import (
     _check_argument,
     desaturate,
     get_color_cycle,
@@ -698,23 +699,26 @@ class VectorPlotter:
 
         if x is None and y is None:
             self.input_format = "wide"
-            plot_data, variables = self._assign_variables_wideform(
+            frame, names = self._assign_variables_wideform(
                 data, **variables,
             )
         else:
+            # When dealing with long-form input, use the newer PlotData
+            # object (internal but introduced for the objects interface)
+            # to centralize / standardize data consumption logic.
             self.input_format = "long"
-            plot_data, variables = self._assign_variables_longform(
-                data, **variables,
-            )
+            plot_data = PlotData(data, variables)
+            frame = plot_data.frame
+            names = plot_data.names
 
-        self.plot_data = plot_data
-        self.variables = variables
+        self.plot_data = frame
+        self.variables = names
         self.var_types = {
             v: variable_type(
-                plot_data[v],
+                frame[v],
                 boolean_type="numeric" if v in "xy" else "categorical"
             )
-            for v in variables
+            for v in names
         }
 
         return self
@@ -861,120 +865,6 @@ class VectorPlotter:
 
         return plot_data, variables
 
-    def _assign_variables_longform(self, data=None, **kwargs):
-        """Define plot variables given long-form data and/or vector inputs.
-
-        Parameters
-        ----------
-        data : dict-like collection of vectors
-            Input data where variable names map to vector values.
-        kwargs : variable -> data mappings
-            Keys are seaborn variables (x, y, hue, ...) and values are vectors
-            in any format that can construct a :class:`pandas.DataFrame` or
-            names of columns or index levels in ``data``.
-
-        Returns
-        -------
-        plot_data : :class:`pandas.DataFrame`
-            Long-form data object mapping seaborn variables (x, y, hue, ...)
-            to data vectors.
-        variables : dict
-            Keys are defined seaborn variables; values are names inferred from
-            the inputs (or None when no name can be determined).
-
-        Raises
-        ------
-        ValueError
-            When variables are strings that don't appear in ``data``.
-
-        """
-        plot_data = {}
-        variables = {}
-
-        # Data is optional; all variables can be defined as vectors
-        if data is None:
-            data = {}
-
-        # TODO should we try a data.to_dict() or similar here to more
-        # generally accept objects with that interface?
-        # Note that dict(df) also works for pandas, and gives us what we
-        # want, whereas DataFrame.to_dict() gives a nested dict instead of
-        # a dict of series.
-
-        # Variables can also be extracted from the index attribute
-        # TODO is this the most general way to enable it?
-        # There is no index.to_dict on multiindex, unfortunately
-        try:
-            index = data.index.to_frame()
-        except AttributeError:
-            index = {}
-
-        # The caller will determine the order of variables in plot_data
-        for key, val in kwargs.items():
-
-            # First try to treat the argument as a key for the data collection.
-            # But be flexible about what can be used as a key.
-            # Usually it will be a string, but allow numbers or tuples too when
-            # taking from the main data object. Only allow strings to reference
-            # fields in the index, because otherwise there is too much ambiguity.
-            try:
-                val_as_data_key = (
-                    val in data
-                    or (isinstance(val, (str, bytes)) and val in index)
-                )
-            except (KeyError, TypeError):
-                val_as_data_key = False
-
-            if val_as_data_key:
-
-                # We know that __getitem__ will work
-
-                if val in data:
-                    plot_data[key] = data[val]
-                elif val in index:
-                    plot_data[key] = index[val]
-                variables[key] = val
-
-            elif isinstance(val, (str, bytes)):
-
-                # This looks like a column name but we don't know what it means!
-
-                err = f"Could not interpret value `{val}` for parameter `{key}`"
-                raise ValueError(err)
-
-            else:
-
-                # Otherwise, assume the value is itself data
-
-                # Raise when data object is present and a vector can't matched
-                if isinstance(data, pd.DataFrame) and not isinstance(val, pd.Series):
-                    if np.ndim(val) and len(data) != len(val):
-                        val_cls = val.__class__.__name__
-                        err = (
-                            f"Length of {val_cls} vectors must match length of `data`"
-                            f" when both are used, but `data` has length {len(data)}"
-                            f" and the vector passed to `{key}` has length {len(val)}."
-                        )
-                        raise ValueError(err)
-
-                plot_data[key] = val
-
-                # Try to infer the name of the variable
-                variables[key] = getattr(val, "name", None)
-
-        # Construct a tidy plot DataFrame. This will convert a number of
-        # types automatically, aligning on index in case of pandas objects
-        plot_data = pd.DataFrame(plot_data)
-
-        # Reduce the variables dictionary to fields with valid data
-        variables = {
-            var: name
-            for var, name in variables.items()
-            if plot_data[var].notnull().any()
-        }
-
-        return plot_data, variables
-
     def iter_data(
         self, grouping_vars=None, *,
         reverse=False, from_comp_data=False,
diff --git a/seaborn/_core/data.py b/seaborn/_core/data.py
index 535fafe8..c17bfe95 100644
--- a/seaborn/_core/data.py
+++ b/seaborn/_core/data.py
@@ -5,11 +5,13 @@ from __future__ import annotations
 
 from collections.abc import Mapping, Sized
 from typing import cast
+import warnings
 
 import pandas as pd
 from pandas import DataFrame
 
 from seaborn._core.typing import DataSource, VariableSpec, ColumnName
+from seaborn.utils import _version_predates
 
 
 class PlotData:
@@ -52,13 +54,19 @@ class PlotData:
         variables: dict[str, VariableSpec],
     ):
 
+        data = handle_data_source(data)
         frame, names, ids = self._assign_variables(data, variables)
 
         self.frame = frame
         self.names = names
         self.ids = ids
 
-        self.frames = {}  # TODO this is a hack, remove
+        # The reason we possibly have a dictionary of frames is to support the
+        # Plot.pair operation, post scaling, where each x/y variable needs its
+        # own frame. This feels pretty clumsy and there are a bunch of places in
+        # the client code with awkard if frame / elif frames constructions.
+        # It would be great to have a cleaner abstraction here.
+        self.frames = {}
 
         self.source_data = data
         self.source_vars = variables
@@ -75,7 +83,7 @@ class PlotData:
         variables: dict[str, VariableSpec] | None,
     ) -> PlotData:
         """Add, replace, or drop variables and return as a new dataset."""
-        # Inherit the original source of the upsteam data by default
+        # Inherit the original source of the upstream data by default
         if data is None:
             data = self.source_data
 
@@ -118,7 +126,7 @@ class PlotData:
 
     def _assign_variables(
         self,
-        data: DataSource,
+        data: DataFrame | Mapping | None,
         variables: dict[str, VariableSpec],
     ) -> tuple[DataFrame, dict[str, str | None], dict[str, str | int]]:
         """
@@ -147,6 +155,8 @@ class PlotData:
 
         Raises
         ------
+        TypeError
+            When data source is not a DataFrame or Mapping.
         ValueError
             When variables are strings that don't appear in `data`, or when they are
             non-indexed vector datatypes that have a different length from `data`.
@@ -162,15 +172,12 @@ class PlotData:
         ids = {}
 
         given_data = data is not None
-        if data is not None:
-            source_data = data
-        else:
+        if data is None:
             # Data is optional; all variables can be defined as vectors
             # But simplify downstream code by always having a usable source data object
             source_data = {}
-
-        # TODO Generally interested in accepting a generic DataFrame interface
-        # Track https://data-apis.org/ for development
+        else:
+            source_data = data
 
         # Variables can also be extracted from the index of a DataFrame
         if isinstance(source_data, pd.DataFrame):
@@ -258,3 +265,55 @@ class PlotData:
         frame = pd.DataFrame(plot_data)
 
         return frame, names, ids
+
+
+def handle_data_source(data: object) -> pd.DataFrame | Mapping | None:
+    """Convert the data source object to a common union representation."""
+    if isinstance(data, pd.DataFrame) or hasattr(data, "__dataframe__"):
+        # Check for pd.DataFrame inheritance could be removed once
+        # minimal pandas version supports dataframe interchange (1.5.0).
+        data = convert_dataframe_to_pandas(data)
+    elif data is not None and not isinstance(data, Mapping):
+        err = f"Data source must be a DataFrame or Mapping, not {type(data)!r}."
+        raise TypeError(err)
+
+    return data
+
+
+def convert_dataframe_to_pandas(data: object) -> pd.DataFrame:
+    """Use the DataFrame exchange protocol, or fail gracefully."""
+    if isinstance(data, pd.DataFrame):
+        return data
+
+    if not hasattr(pd.api, "interchange"):
+        msg = (
+            "Support for non-pandas DataFrame objects requires a version of pandas "
+            "that implements the DataFrame interchange protocol. Please upgrade "
+            "your pandas version or coerce your data to pandas before passing "
+            "it to seaborn."
+        )
+        raise TypeError(msg)
+
+    if _version_predates(pd, "2.0.2"):
+        msg = (
+            "DataFrame interchange with pandas<2.0.2 has some known issues. "
+            f"You are using pandas {pd.__version__}. "
+            "Continuing, but it is recommended to carefully inspect the results and to "
+            "consider upgrading."
+        )
+        warnings.warn(msg, stacklevel=2)
+
+    try:
+        # This is going to convert all columns in the input dataframe, even though
+        # we may only need one or two of them. It would be more efficient to select
+        # the columns that are going to be used in the plot prior to interchange.
+        # Solving that in general is a hard problem, especially with the objects
+        # interface where variables passed in Plot() may only be referenced later
+        # in Plot.add(). But noting here in case this seems to be a bottleneck.
+        return pd.api.interchange.from_dataframe(data)
+    except Exception as err:
+        msg = (
+            "Encountered an exception when converting data source "
+            "to a pandas DataFrame. See traceback above for details."
+        )
+        raise RuntimeError(msg) from err
diff --git a/seaborn/_core/plot.py b/seaborn/_core/plot.py
index 2ec7b6ec..d07e99bc 100644
--- a/seaborn/_core/plot.py
+++ b/seaborn/_core/plot.py
@@ -346,9 +346,10 @@ class Plot:
             err = "Plot() accepts no more than 3 positional arguments (data, x, y)."
             raise TypeError(err)
 
-        # TODO need some clearer way to differentiate data / vector here
-        # (There might be an abstract DataFrame class to use here?)
-        if isinstance(args[0], (abc.Mapping, pd.DataFrame)):
+        if (
+            isinstance(args[0], (abc.Mapping, pd.DataFrame))
+            or hasattr(args[0], "__dataframe__")
+        ):
             if data is not None:
                 raise TypeError("`data` given by both name and position.")
             data, args = args[0], args[1:]
diff --git a/seaborn/_core/typing.py b/seaborn/_core/typing.py
index cc3522d4..9bdf8a6e 100644
--- a/seaborn/_core/typing.py
+++ b/seaborn/_core/typing.py
@@ -1,11 +1,11 @@
 from __future__ import annotations
 
+from collections.abc import Iterable, Mapping
 from datetime import date, datetime, timedelta
-from typing import Any, Optional, Union, Mapping, Tuple, List, Dict
-from collections.abc import Hashable, Iterable
+from typing import Any, Optional, Union, Tuple, List, Dict
 
 from numpy import ndarray  # TODO use ArrayLike?
-from pandas import DataFrame, Series, Index, Timestamp, Timedelta
+from pandas import Series, Index, Timestamp, Timedelta
 from matplotlib.colors import Colormap, Normalize
 
 
@@ -17,7 +17,11 @@ Vector = Union[Series, Index, ndarray]
 VariableSpec = Union[ColumnName, Vector, None]
 VariableSpecList = Union[List[VariableSpec], Index, None]
 
-DataSource = Union[DataFrame, Mapping[Hashable, Vector], None]
+# A DataSource can be an object implementing __dataframe__, or a Mapping
+# (and is optional in all contexts where it is used).
+# I don't think there's an abc for "has __dataframe__", so we type as object
+# but keep the (slightly odd) Union alias for better user-facing annotations.
+DataSource = Union[object, Mapping, None]
 
 OrderSpec = Union[Iterable, None]  # TODO technically str is iterable
 NormSpec = Union[Tuple[Optional[float], Optional[float]], Normalize, None]
diff --git a/seaborn/axisgrid.py b/seaborn/axisgrid.py
index 69a4e37f..7b0e51db 100644
--- a/seaborn/axisgrid.py
+++ b/seaborn/axisgrid.py
@@ -10,6 +10,7 @@ import matplotlib as mpl
 import matplotlib.pyplot as plt
 
 from ._base import VectorPlotter, variable_type, categorical_order
+from ._core.data import handle_data_source
 from ._compat import share_axis, get_legend_handles
 from . import utils
 from .utils import (
@@ -374,6 +375,7 @@ class FacetGrid(Grid):
     ):
 
         super().__init__()
+        data = handle_data_source(data)
 
         # Determine the hue facet layer information
         hue_var = hue
@@ -1240,6 +1242,7 @@ class PairGrid(Grid):
         """
 
         super().__init__()
+        data = handle_data_source(data)
 
         # Sort out the variables that define the grid
         numeric_cols = self._find_numeric_cols(data)
diff --git a/seaborn/categorical.py b/seaborn/categorical.py
index c18f3a10..5e9d8c70 100644
--- a/seaborn/categorical.py
+++ b/seaborn/categorical.py
@@ -2593,10 +2593,10 @@ def countplot(
 
     if x is None and y is not None:
         orient = "y"
-        x = 1
+        x = 1 if list(y) else None
     elif x is not None and y is None:
         orient = "x"
-        y = 1
+        y = 1 if list(x) else None
     elif x is not None and y is not None:
         raise TypeError("Cannot pass values for both `x` and `y`.")
 
diff --git a/seaborn/distributions.py b/seaborn/distributions.py
index 8cf7f231..c8c6b5f0 100644
--- a/seaborn/distributions.py
+++ b/seaborn/distributions.py
@@ -2012,6 +2012,7 @@ def rugplot(
             x = data
         elif axis == "y":
             y = data
+        data = None
         msg = textwrap.dedent(f"""\n
         The `axis` parameter has been deprecated; use the `{axis}` parameter instead.
         Please update your code; this will become an error in seaborn v0.13.0.


Test patch:
diff --git a/tests/_core/test_data.py b/tests/_core/test_data.py
index aeffc9ed..0e67ed37 100644
--- a/tests/_core/test_data.py
+++ b/tests/_core/test_data.py
@@ -144,7 +144,7 @@ class TestPlotData:
             PlotData(long_df, {"x": "x", "y": vector})
 
     @pytest.mark.parametrize(
-        "arg", [[], np.array([]), pd.DataFrame()],
+        "arg", [{}, pd.DataFrame()],
     )
     def test_empty_data_input(self, arg):
 
@@ -397,3 +397,43 @@ class TestPlotData:
         p = PlotData(d1, {"x": "a"}).join(d2, {"y": "a"}).join(None, {"y": "a"})
         assert_vector_equal(p.frame["x"], d1["a"])
         assert_vector_equal(p.frame["y"], d1["a"])
+
+    def test_bad_type(self, flat_list):
+
+        err = "Data source must be a DataFrame or Mapping"
+        with pytest.raises(TypeError, match=err):
+            PlotData(flat_list, {})
+
+    @pytest.mark.skipif(
+        condition=not hasattr(pd.api, "interchange"),
+        reason="Tests behavior assuming support for dataframe interchange"
+    )
+    def test_data_interchange(self, mock_long_df, long_df):
+
+        variables = {"x": "x", "y": "z", "color": "a"}
+        p = PlotData(mock_long_df, variables)
+        for var, col in variables.items():
+            assert_vector_equal(p.frame[var], long_df[col])
+
+        p = PlotData(mock_long_df, {**variables, "color": long_df["a"]})
+        for var, col in variables.items():
+            assert_vector_equal(p.frame[var], long_df[col])
+
+    @pytest.mark.skipif(
+        condition=not hasattr(pd.api, "interchange"),
+        reason="Tests behavior assuming support for dataframe interchange"
+    )
+    def test_data_interchange_failure(self, mock_long_df):
+
+        mock_long_df._data = None  # Break __dataframe__()
+        with pytest.raises(RuntimeError, match="Encountered an exception"):
+            PlotData(mock_long_df, {"x": "x"})
+
+    @pytest.mark.skipif(
+        condition=hasattr(pd.api, "interchange"),
+        reason="Tests graceful failure without support for dataframe interchange"
+    )
+    def test_data_interchange_support_test(self, mock_long_df):
+
+        with pytest.raises(TypeError, match="Support for non-pandas DataFrame"):
+            PlotData(mock_long_df, {"x": "x"})
diff --git a/tests/_core/test_plot.py b/tests/_core/test_plot.py
index 355537f7..bf69864c 100644
--- a/tests/_core/test_plot.py
+++ b/tests/_core/test_plot.py
@@ -170,6 +170,15 @@ class TestInit:
         assert p._data.source_data is None
         assert list(p._data.source_vars) == ["x"]
 
+    @pytest.mark.skipif(
+        condition=not hasattr(pd.api, "interchange"),
+        reason="Tests behavior assuming support for dataframe interchange"
+    )
+    def test_positional_interchangeable_dataframe(self, mock_long_df, long_df):
+
+        p = Plot(mock_long_df, x="x")
+        assert_frame_equal(p._data.source_data, long_df)
+
     def test_positional_too_many(self, long_df):
 
         err = r"Plot\(\) accepts no more than 3 positional arguments \(data, x, y\)"
diff --git a/tests/conftest.py b/tests/conftest.py
index 01d93a49..9f9eef49 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -178,3 +178,19 @@ def object_df(rng, long_df):
 def null_series(flat_series):
 
     return pd.Series(index=flat_series.index, dtype='float64')
+
+
+class MockInterchangeableDataFrame:
+    # Mock object that is not a pandas.DataFrame but that can
+    # be converted to one via the DataFrame exchange protocol
+    def __init__(self, data):
+        self._data = data
+
+    def __dataframe__(self, *args, **kwargs):
+        return self._data.__dataframe__(*args, **kwargs)
+
+
+@pytest.fixture
+def mock_long_df(long_df):
+
+    return MockInterchangeableDataFrame(long_df)
diff --git a/tests/test_axisgrid.py b/tests/test_axisgrid.py
index 1390fc05..30f930ae 100644
--- a/tests/test_axisgrid.py
+++ b/tests/test_axisgrid.py
@@ -707,6 +707,19 @@ class TestFacetGrid:
                     assert mpl.colors.same_color(tick.tick2line.get_color(), color)
                     assert tick.get_pad() == pad
 
+    @pytest.mark.skipif(
+        condition=not hasattr(pd.api, "interchange"),
+        reason="Tests behavior assuming support for dataframe interchange"
+    )
+    def test_data_interchange(self, mock_long_df, long_df):
+
+        g = ag.FacetGrid(mock_long_df, col="a", row="b")
+        g.map(scatterplot, "x", "y")
+
+        assert g.axes.shape == (long_df["b"].nunique(), long_df["a"].nunique())
+        for ax in g.axes.flat:
+            assert len(ax.collections) == 1
+
 
 class TestPairGrid:
 
@@ -1462,6 +1475,18 @@ class TestPairGrid:
                     assert mpl.colors.same_color(tick.tick2line.get_color(), color)
                     assert tick.get_pad() == pad
 
+    @pytest.mark.skipif(
+        condition=not hasattr(pd.api, "interchange"),
+        reason="Tests behavior assuming support for dataframe interchange"
+    )
+    def test_data_interchange(self, mock_long_df, long_df):
+
+        g = ag.PairGrid(mock_long_df, vars=["x", "y", "z"], hue="a")
+        g.map(scatterplot)
+        assert g.axes.shape == (3, 3)
+        for ax in g.axes.flat:
+            assert len(ax.collections) == long_df["a"].nunique() + 1
+
 
 class TestJointGrid:
 
diff --git a/tests/test_base.py b/tests/test_base.py
index c29e8a06..e6cfd8c1 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -757,7 +757,7 @@ class TestVectorPlotter:
         p = VectorPlotter()
         p.assign_variables(data=long_df, variables={"x": name})
         assert_array_equal(p.plot_data["x"], long_df[name])
-        assert p.variables["x"] == name
+        assert p.variables["x"] == str(name)
 
     def test_long_hierarchical_index(self, rng):
 
@@ -771,7 +771,7 @@ class TestVectorPlotter:
         p = VectorPlotter()
         p.assign_variables(data=df, variables={var: name})
         assert_array_equal(p.plot_data[var], df[name])
-        assert p.variables[var] == name
+        assert p.variables[var] == str(name)
 
     def test_long_scalar_and_data(self, long_df):
 
@@ -788,7 +788,7 @@ class TestVectorPlotter:
 
     def test_long_unknown_error(self, long_df):
 
-        err = "Could not interpret value `what` for parameter `hue`"
+        err = "Could not interpret value `what` for `hue`"
         with pytest.raises(ValueError, match=err):
             VectorPlotter(data=long_df, variables={"x": "x", "hue": "what"})