From 26b3d0e29ff5286c32e6f934b2ed64cd20207496 Mon Sep 17 00:00:00 2001 From: Michael Waskom Date: Mon, 19 Dec 2022 18:58:31 -0500 Subject: [PATCH 1/9] Mark numpy 1.24.0 as non-compatible (#3194) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1e03f964bd..8ba6b691f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] requires-python = ">=3.7" dependencies = [ - "numpy>=1.17", + "numpy>=1.17,!=1.24.0", "pandas>=0.25", "matplotlib>=3.1,!=3.6.1", "typing_extensions; python_version < '3.8'", From d25872b0fc99dbf7e666a91f59bd4ed125186aa1 Mon Sep 17 00:00:00 2001 From: Ivan Kargapoltsev Date: Tue, 20 Dec 2022 02:59:20 +0300 Subject: [PATCH 2/9] fix typos (#3193) --- doc/_tutorial/axis_grids.ipynb | 2 +- doc/_tutorial/regression.ipynb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/_tutorial/axis_grids.ipynb b/doc/_tutorial/axis_grids.ipynb index 2c3aacdc24..4a652d6c00 100644 --- a/doc/_tutorial/axis_grids.ipynb +++ b/doc/_tutorial/axis_grids.ipynb @@ -190,7 +190,7 @@ "cell_type": "raw", "metadata": {}, "source": [ - "Any seaborn color palette (i.e., something that can be passed to :func:`color_palette()` can be provided. You can also use a dictionary that maps the names of values in the ``hue`` variable to valid matplotlib colors:" + "Any seaborn color palette (i.e., something that can be passed to :func:`color_palette()`) can be provided. You can also use a dictionary that maps the names of values in the ``hue`` variable to valid matplotlib colors:" ] }, { diff --git a/doc/_tutorial/regression.ipynb b/doc/_tutorial/regression.ipynb index 72e7c68667..91ff460eb3 100644 --- a/doc/_tutorial/regression.ipynb +++ b/doc/_tutorial/regression.ipynb @@ -78,7 +78,7 @@ "cell_type": "raw", "metadata": {}, "source": [ - "These functions draw similar plots, but :func:regplot` is an :doc:`axes-level function `, and :func:`lmplot` is a figure-level function. Additionally, :func:`regplot` accepts the ``x`` and ``y`` variables in a variety of formats including simple numpy arrays, :class:`pandas.Series` objects, or as references to variables in a :class:`pandas.DataFrame` object passed to `data`. In contrast, :func:`lmplot` has `data` as a required parameter and the `x` and `y` variables must be specified as strings. Finally, only :func:`lmplot` has `hue` as a parameter.\n", + "These functions draw similar plots, but :func:`regplot` is an :doc:`axes-level function `, and :func:`lmplot` is a figure-level function. Additionally, :func:`regplot` accepts the ``x`` and ``y`` variables in a variety of formats including simple numpy arrays, :class:`pandas.Series` objects, or as references to variables in a :class:`pandas.DataFrame` object passed to `data`. In contrast, :func:`lmplot` has `data` as a required parameter and the `x` and `y` variables must be specified as strings. Finally, only :func:`lmplot` has `hue` as a parameter.\n", "\n", "The core functionality is otherwise similar, though, so this tutorial will focus on :func:`lmplot`:.\n", "\n", From 87e097275ce468db8f18c80ef8970310cfef1fe8 Mon Sep 17 00:00:00 2001 From: Michael Waskom Date: Sat, 24 Dec 2022 12:02:36 -0500 Subject: [PATCH 3/9] Improve robustness to empty data in Plot and Nominal (#3202) * Improve robustness to missing data in Plot and Nominal * Fix Plot.on tests to work with new logic --- doc/whatsnew/v0.12.2.rst | 2 ++ seaborn/_core/plot.py | 7 +++---- seaborn/_core/scales.py | 2 +- tests/_core/test_plot.py | 9 +++++---- tests/_core/test_scales.py | 6 ++++++ 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/doc/whatsnew/v0.12.2.rst b/doc/whatsnew/v0.12.2.rst index ad12a778e1..aa8def9a0b 100644 --- a/doc/whatsnew/v0.12.2.rst +++ b/doc/whatsnew/v0.12.2.rst @@ -8,6 +8,8 @@ v0.12.2 (Unreleased) - |Fix| Fixed a bug where legends for numeric variables with large values with be incorrectly shown (i.e. with a missing offset or exponent; :pr:`3187`). +- |Fix| Improve robustness to empty data in several components of the objects interface (:pr:`3202`). + - |Fix| Fixed a regression in v0.12.0 where manually-added labels could have duplicate legend entries (:pr:`3116`). - |Fix| Fixed a bug in :func:`histplot` with `kde=True` and `log_scale=True` where the curve was not scaled properly (:pr:`3173`). diff --git a/seaborn/_core/plot.py b/seaborn/_core/plot.py index 8915616b90..64f59cb239 100644 --- a/seaborn/_core/plot.py +++ b/seaborn/_core/plot.py @@ -1466,8 +1466,6 @@ def _setup_split_generator( self, grouping_vars: list[str], df: DataFrame, subplots: list[dict[str, Any]], ) -> Callable[[], Generator]: - allow_empty = False # TODO will need to recreate previous categorical plots - grouping_keys = [] grouping_vars = [ v for v in grouping_vars if v in df and v not in ["col", "row"] @@ -1506,7 +1504,8 @@ def split_generator(keep_na=False) -> Generator: subplot_keys[dim] = view[dim] if not grouping_vars or not any(grouping_keys): - yield subplot_keys, axes_df.copy(), view["ax"] + if not axes_df.empty: + yield subplot_keys, axes_df.copy(), view["ax"] continue grouped_df = axes_df.groupby(grouping_vars, sort=False, as_index=False) @@ -1526,7 +1525,7 @@ def split_generator(keep_na=False) -> Generator: # case this option could be removed df_subset = axes_df.loc[[]] - if df_subset.empty and not allow_empty: + if df_subset.empty: continue sub_vars = dict(zip(grouping_vars, key)) diff --git a/seaborn/_core/scales.py b/seaborn/_core/scales.py index c91f6fdc46..6c9ecbc902 100644 --- a/seaborn/_core/scales.py +++ b/seaborn/_core/scales.py @@ -163,7 +163,7 @@ def _setup( new = new.label() # TODO flexibility over format() which isn't great for numbers / dates - stringify = np.vectorize(format) + stringify = np.vectorize(format, otypes=["object"]) units_seed = categorical_order(data, new.order) diff --git a/tests/_core/test_plot.py b/tests/_core/test_plot.py index 2bff6bed17..506739624e 100644 --- a/tests/_core/test_plot.py +++ b/tests/_core/test_plot.py @@ -680,8 +680,9 @@ def test_matplotlib_object_creation(self): def test_empty(self): m = MockMark() - Plot().plot() + Plot().add(m).plot() assert m.n_splits == 0 + assert not m.passed_data def test_no_orient_variance(self): @@ -1086,7 +1087,7 @@ def test_on_axes(self): ax = mpl.figure.Figure().subplots() m = MockMark() - p = Plot().on(ax).add(m).plot() + p = Plot([1], [2]).on(ax).add(m).plot() assert m.passed_axes == [ax] assert p._figure is ax.figure @@ -1095,7 +1096,7 @@ def test_on_figure(self, facet): f = mpl.figure.Figure() m = MockMark() - p = Plot().on(f).add(m) + p = Plot([1, 2], [3, 4]).on(f).add(m) if facet: p = p.facet(["a", "b"]) p = p.plot() @@ -1112,7 +1113,7 @@ def test_on_subfigure(self, facet): sf1, sf2 = mpl.figure.Figure().subfigures(2) sf1.subplots() m = MockMark() - p = Plot().on(sf2).add(m) + p = Plot([1, 2], [3, 4]).on(sf2).add(m) if facet: p = p.facet(["a", "b"]) p = p.plot() diff --git a/tests/_core/test_scales.py b/tests/_core/test_scales.py index c4b39f5d34..5baf53ceaf 100644 --- a/tests/_core/test_scales.py +++ b/tests/_core/test_scales.py @@ -555,6 +555,12 @@ class MockProperty(IntervalProperty): s = Nominal((2, 4))._setup(x, MockProperty()) assert_array_equal(s(x), [4, np.sqrt(10), 2, np.sqrt(10)]) + def test_empty_data(self): + + x = pd.Series([], dtype=object, name="x") + s = Nominal()._setup(x, Coordinate()) + assert_array_equal(s(x), []) + class TestTemporal: From 4a9e54962a29c12a8b103d75f838e0e795a6974d Mon Sep 17 00:00:00 2001 From: Michael Waskom Date: Tue, 27 Dec 2022 20:09:15 -0500 Subject: [PATCH 4/9] Improve user feedback for errors during plot compilation (#3203) * Improve user feedback for errors during plot compilation * Update release notes and fix flaky test * Fix pytest.raises usage and improve tests * Simplify comments for cleaner tracebacks --- doc/whatsnew/v0.12.2.rst | 4 ++- seaborn/_core/exceptions.py | 32 +++++++++++++++++++++ seaborn/_core/plot.py | 22 +++++++++------ seaborn/_marks/base.py | 9 +++++- seaborn/palettes.py | 2 +- tests/_core/test_plot.py | 56 +++++++++++++++++++++++++++++++++++-- tests/_core/test_scales.py | 2 +- tests/test_distributions.py | 4 +-- 8 files changed, 114 insertions(+), 17 deletions(-) create mode 100644 seaborn/_core/exceptions.py diff --git a/doc/whatsnew/v0.12.2.rst b/doc/whatsnew/v0.12.2.rst index aa8def9a0b..927c4fa0a2 100644 --- a/doc/whatsnew/v0.12.2.rst +++ b/doc/whatsnew/v0.12.2.rst @@ -6,9 +6,11 @@ v0.12.2 (Unreleased) - |Enhancement| Automatic mark widths are now calculated separately for unshared facet axes (:pr:`3119`). +- |Enhancement| Improved user feedback for failures during plot compilation by catching exceptions an reraising with a `PlotSpecError` that provides additional context (:pr:`3203`). + - |Fix| Fixed a bug where legends for numeric variables with large values with be incorrectly shown (i.e. with a missing offset or exponent; :pr:`3187`). -- |Fix| Improve robustness to empty data in several components of the objects interface (:pr:`3202`). +- |Fix| Improved robustness to empty data in several components of the objects interface (:pr:`3202`). - |Fix| Fixed a regression in v0.12.0 where manually-added labels could have duplicate legend entries (:pr:`3116`). diff --git a/seaborn/_core/exceptions.py b/seaborn/_core/exceptions.py new file mode 100644 index 0000000000..048443b0f8 --- /dev/null +++ b/seaborn/_core/exceptions.py @@ -0,0 +1,32 @@ +""" +Custom exceptions for the seaborn.objects interface. + +This is very lightweight, but it's a separate module to avoid circular imports. + +""" +from __future__ import annotations + + +class PlotSpecError(RuntimeError): + """ + Error class raised from seaborn.objects.Plot for compile-time failures. + + In the declarative Plot interface, exceptions may not be triggered immediately + by bad user input (and validation at input time may not be possible). This class + is used to signal that indirect dependency. It should be raised in an exception + chain when compile-time operations fail with an error message providing useful + context (e.g., scaling errors could specify the variable that failed.) + + """ + @classmethod + def _during(cls, step: str, var: str = "") -> PlotSpecError: + """ + Initialize the class to report the failure of a specific operation. + """ + message = [] + if var: + message.append(f"{step} failed for the `{var}` variable.") + else: + message.append(f"{step} failed.") + message.append("See the traceback above for more information.") + return cls(" ".join(message)) diff --git a/seaborn/_core/plot.py b/seaborn/_core/plot.py index 64f59cb239..6c7202c643 100644 --- a/seaborn/_core/plot.py +++ b/seaborn/_core/plot.py @@ -36,6 +36,7 @@ OrderSpec, Default, ) +from seaborn._core.exceptions import PlotSpecError from seaborn._core.rules import categorical_order from seaborn._compat import set_scale_obj, set_layout_engine from seaborn.rcmod import axes_style, plotting_context @@ -1249,14 +1250,13 @@ def _setup_scales( if scale is None: self._scales[var] = Scale._identity() else: - self._scales[var] = scale._setup(var_df[var], prop) + try: + self._scales[var] = scale._setup(var_df[var], prop) + except Exception as err: + raise PlotSpecError._during("Scale setup", var) from err - # Everything below here applies only to coordinate variables - # We additionally skip it when we're working with a value - # that is derived from a coordinate we've already processed. - # e.g., the Stat consumed y and added ymin/ymax. In that case, - # we've already setup the y scale and ymin/max are in scale space. if axis is None or (var != coord and coord in p._variables): + # Everything below here applies only to coordinate variables continue # Set up an empty series to receive the transformed values. @@ -1276,9 +1276,15 @@ def _setup_scales( for layer, new_series in zip(layers, transformed_data): layer_df = layer["data"].frame - if var in layer_df: - idx = self._get_subplot_index(layer_df, view) + if var not in layer_df: + continue + + idx = self._get_subplot_index(layer_df, view) + try: new_series.loc[idx] = view_scale(layer_df.loc[idx, var]) + except Exception as err: + spec_error = PlotSpecError._during("Scaling operation", var) + raise spec_error from err # Now the transformed data series are complete, set update the layer data for layer, new_series in zip(layers, transformed_data): diff --git a/seaborn/_marks/base.py b/seaborn/_marks/base.py index 87e0216d9d..324d0221e7 100644 --- a/seaborn/_marks/base.py +++ b/seaborn/_marks/base.py @@ -20,6 +20,7 @@ DashPattern, DashPatternWithOffset, ) +from seaborn._core.exceptions import PlotSpecError class Mappable: @@ -172,7 +173,13 @@ def _resolve( # TODO Might this obviate the identity scale? Just don't add a scale? feature = data[name] else: - feature = scales[name](data[name]) + scale = scales[name] + value = data[name] + try: + feature = scale(value) + except Exception as err: + raise PlotSpecError._during("Scaling operation", name) from err + if return_array: feature = np.asarray(feature) return feature diff --git a/seaborn/palettes.py b/seaborn/palettes.py index 3306b0f2e9..f1214b2a0f 100644 --- a/seaborn/palettes.py +++ b/seaborn/palettes.py @@ -234,7 +234,7 @@ def color_palette(palette=None, n_colors=None, desat=None, as_cmap=False): # Perhaps a named matplotlib colormap? palette = mpl_palette(palette, n_colors, as_cmap=as_cmap) except (ValueError, KeyError): # Error class changed in mpl36 - raise ValueError(f"{palette} is not a valid palette name") + raise ValueError(f"{palette!r} is not a valid palette name") if desat is not None: palette = [desaturate(c, desat) for c in palette] diff --git a/tests/_core/test_plot.py b/tests/_core/test_plot.py index 506739624e..6862dbf805 100644 --- a/tests/_core/test_plot.py +++ b/tests/_core/test_plot.py @@ -15,12 +15,14 @@ from numpy.testing import assert_array_equal, assert_array_almost_equal from seaborn._core.plot import Plot, Default -from seaborn._core.scales import Nominal, Continuous -from seaborn._core.rules import categorical_order +from seaborn._core.scales import Continuous, Nominal, Temporal from seaborn._core.moves import Move, Shift, Dodge -from seaborn._stats.aggregation import Agg +from seaborn._core.rules import categorical_order +from seaborn._core.exceptions import PlotSpecError from seaborn._marks.base import Mark from seaborn._stats.base import Stat +from seaborn._marks.dot import Dot +from seaborn._stats.aggregation import Agg from seaborn.external.version import Version assert_vector_equal = functools.partial( @@ -1249,6 +1251,54 @@ def test_title_facet_function(self): assert ax.get_title() == expected +class TestExceptions: + + def test_scale_setup(self): + + x = y = color = ["a", "b"] + bad_palette = "not_a_palette" + p = Plot(x, y, color=color).add(MockMark()).scale(color=bad_palette) + + msg = "Scale setup failed for the `color` variable." + with pytest.raises(PlotSpecError, match=msg) as err: + p.plot() + assert isinstance(err.value.__cause__, ValueError) + assert bad_palette in str(err.value.__cause__) + + def test_coordinate_scaling(self): + + x = ["a", "b"] + y = [1, 2] + p = Plot(x, y).add(MockMark()).scale(x=Temporal()) + + msg = "Scaling operation failed for the `x` variable." + with pytest.raises(PlotSpecError, match=msg) as err: + p.plot() + # Don't test the cause contents b/c matplotlib owns them here. + assert hasattr(err.value, "__cause__") + + def test_semantic_scaling(self): + + class ErrorRaising(Continuous): + + def _setup(self, data, prop, axis=None): + + def f(x): + raise ValueError("This is a test") + + new = super()._setup(data, prop, axis) + new._pipeline = [f] + return new + + x = y = color = [1, 2] + p = Plot(x, y, color=color).add(Dot()).scale(color=ErrorRaising()) + msg = "Scaling operation failed for the `color` variable." + with pytest.raises(PlotSpecError, match=msg) as err: + p.plot() + assert isinstance(err.value.__cause__, ValueError) + assert str(err.value.__cause__) == "This is a test" + + class TestFacetInterface: @pytest.fixture(scope="class", params=["row", "col"]) diff --git a/tests/_core/test_scales.py b/tests/_core/test_scales.py index 5baf53ceaf..2d967cc217 100644 --- a/tests/_core/test_scales.py +++ b/tests/_core/test_scales.py @@ -448,7 +448,7 @@ def test_color_alpha_in_palette(self, x): def test_color_unknown_palette(self, x): pal = "not_a_palette" - err = f"{pal} is not a valid palette name" + err = f"'{pal}' is not a valid palette name" with pytest.raises(ValueError, match=err): Nominal(pal)._setup(x, Color()) diff --git a/tests/test_distributions.py b/tests/test_distributions.py index 78cd5fe448..c5ac036c35 100644 --- a/tests/test_distributions.py +++ b/tests/test_distributions.py @@ -1934,8 +1934,8 @@ def test_mesh_log_scale(self, rng): edges = itertools.product(y_edges[:-1], x_edges[:-1]) for i, (y_i, x_i) in enumerate(edges): path = mesh.get_paths()[i] - assert path.vertices[0, 0] == 10 ** x_i - assert path.vertices[0, 1] == 10 ** y_i + assert path.vertices[0, 0] == pytest.approx(10 ** x_i) + assert path.vertices[0, 1] == pytest.approx(10 ** y_i) def test_mesh_thresh(self, long_df): From 72710354ecc8470683e15f452ba0af841464e84a Mon Sep 17 00:00:00 2001 From: Michael Waskom Date: Tue, 27 Dec 2022 20:27:41 -0500 Subject: [PATCH 5/9] Avoid exception from Continuous scale for normed property (#3190) --- doc/whatsnew/v0.12.2.rst | 2 ++ seaborn/_core/scales.py | 2 +- tests/_core/test_scales.py | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/doc/whatsnew/v0.12.2.rst b/doc/whatsnew/v0.12.2.rst index 927c4fa0a2..423cc58e5c 100644 --- a/doc/whatsnew/v0.12.2.rst +++ b/doc/whatsnew/v0.12.2.rst @@ -14,6 +14,8 @@ v0.12.2 (Unreleased) - |Fix| Fixed a regression in v0.12.0 where manually-added labels could have duplicate legend entries (:pr:`3116`). +- |Fix| Normed properties using a :class:`objects.Continuous` scale no longer raise on boolean data (:pr:`3189`). + - |Fix| Fixed a bug in :func:`histplot` with `kde=True` and `log_scale=True` where the curve was not scaled properly (:pr:`3173`). - |Fix| Fixed a bug in :func:`relplot` where inner axis labels would be shown when axis sharing was disabled (:pr:`3180`). diff --git a/seaborn/_core/scales.py b/seaborn/_core/scales.py index 6c9ecbc902..3c215a60d8 100644 --- a/seaborn/_core/scales.py +++ b/seaborn/_core/scales.py @@ -346,7 +346,7 @@ def _setup( vmin, vmax = data.min(), data.max() else: vmin, vmax = new.norm - vmin, vmax = axis.convert_units((vmin, vmax)) + vmin, vmax = map(float, axis.convert_units((vmin, vmax))) a = forward(vmin) b = forward(vmax) - forward(vmin) diff --git a/tests/_core/test_scales.py b/tests/_core/test_scales.py index 2d967cc217..ac77098c64 100644 --- a/tests/_core/test_scales.py +++ b/tests/_core/test_scales.py @@ -90,6 +90,12 @@ def test_interval_with_range_norm_and_transform(self, x): s = Continuous((2, 3), (10, 100), "log")._setup(x, IntervalProperty()) assert_array_equal(s(x), [1, 2, 3]) + def test_interval_with_bools(self): + + x = pd.Series([True, False, False]) + s = Continuous()._setup(x, IntervalProperty()) + assert_array_equal(s(x), [1, 0, 0]) + def test_color_defaults(self, x): cmap = color_palette("ch:", as_cmap=True) From 5e565d2a1d8e155a9e54bb93d6e0fc2b310ec336 Mon Sep 17 00:00:00 2001 From: Michael Waskom Date: Fri, 30 Dec 2022 11:07:03 -0500 Subject: [PATCH 6/9] Add Boolean scale (#3205) * Add Boolean scale * Pandas backcompat * Add test for new variable_type behavior * Add tests for scale inference based on boolean data * Add Boolean scale tests * Update docs * Add tests for finalization methods * Skip failing test on older matplotlibs * Add 0.12.2 release notes to index and update notebooks --- doc/_docstrings/FacetGrid.ipynb | 2 +- doc/_docstrings/JointGrid.ipynb | 2 +- doc/_docstrings/PairGrid.ipynb | 2 +- doc/_docstrings/axes_style.ipynb | 2 +- doc/_docstrings/barplot.ipynb | 2 +- doc/_docstrings/blend_palette.ipynb | 2 +- doc/_docstrings/boxenplot.ipynb | 2 +- doc/_docstrings/boxplot.ipynb | 2 +- doc/_docstrings/catplot.ipynb | 2 +- doc/_docstrings/clustermap.ipynb | 2 +- doc/_docstrings/color_palette.ipynb | 2 +- doc/_docstrings/countplot.ipynb | 2 +- doc/_docstrings/cubehelix_palette.ipynb | 2 +- doc/_docstrings/dark_palette.ipynb | 2 +- doc/_docstrings/displot.ipynb | 2 +- doc/_docstrings/diverging_palette.ipynb | 2 +- doc/_docstrings/ecdfplot.ipynb | 2 +- doc/_docstrings/heatmap.ipynb | 2 +- doc/_docstrings/histplot.ipynb | 2 +- doc/_docstrings/hls_palette.ipynb | 2 +- doc/_docstrings/husl_palette.ipynb | 2 +- doc/_docstrings/jointplot.ipynb | 2 +- doc/_docstrings/kdeplot.ipynb | 2 +- doc/_docstrings/light_palette.ipynb | 2 +- doc/_docstrings/lineplot.ipynb | 2 +- doc/_docstrings/lmplot.ipynb | 2 +- doc/_docstrings/move_legend.ipynb | 2 +- doc/_docstrings/mpl_palette.ipynb | 2 +- doc/_docstrings/objects.Agg.ipynb | 2 +- doc/_docstrings/objects.Area.ipynb | 2 +- doc/_docstrings/objects.Band.ipynb | 2 +- doc/_docstrings/objects.Bar.ipynb | 2 +- doc/_docstrings/objects.Bars.ipynb | 2 +- doc/_docstrings/objects.Count.ipynb | 2 +- doc/_docstrings/objects.Dash.ipynb | 2 +- doc/_docstrings/objects.Dodge.ipynb | 2 +- doc/_docstrings/objects.Dot.ipynb | 2 +- doc/_docstrings/objects.Dots.ipynb | 2 +- doc/_docstrings/objects.Est.ipynb | 2 +- doc/_docstrings/objects.Hist.ipynb | 2 +- doc/_docstrings/objects.Jitter.ipynb | 2 +- doc/_docstrings/objects.KDE.ipynb | 2 +- doc/_docstrings/objects.Line.ipynb | 2 +- doc/_docstrings/objects.Lines.ipynb | 2 +- doc/_docstrings/objects.Norm.ipynb | 2 +- doc/_docstrings/objects.Path.ipynb | 2 +- doc/_docstrings/objects.Paths.ipynb | 2 +- doc/_docstrings/objects.Perc.ipynb | 2 +- doc/_docstrings/objects.Plot.add.ipynb | 2 +- doc/_docstrings/objects.Plot.facet.ipynb | 2 +- doc/_docstrings/objects.Plot.label.ipynb | 2 +- doc/_docstrings/objects.Plot.layout.ipynb | 2 +- doc/_docstrings/objects.Plot.limit.ipynb | 2 +- doc/_docstrings/objects.Plot.on.ipynb | 2 +- doc/_docstrings/objects.Plot.pair.ipynb | 2 +- doc/_docstrings/objects.Plot.scale.ipynb | 2 +- doc/_docstrings/objects.Plot.share.ipynb | 2 +- doc/_docstrings/objects.Plot.theme.ipynb | 2 +- doc/_docstrings/objects.Range.ipynb | 2 +- doc/_docstrings/objects.Shift.ipynb | 2 +- doc/_docstrings/objects.Stack.ipynb | 2 +- doc/_docstrings/objects.Text.ipynb | 2 +- doc/_docstrings/pairplot.ipynb | 2 +- doc/_docstrings/plotting_context.ipynb | 2 +- doc/_docstrings/pointplot.ipynb | 2 +- doc/_docstrings/regplot.ipynb | 2 +- doc/_docstrings/relplot.ipynb | 2 +- doc/_docstrings/residplot.ipynb | 2 +- doc/_docstrings/rugplot.ipynb | 2 +- doc/_docstrings/scatterplot.ipynb | 2 +- doc/_docstrings/set_context.ipynb | 2 +- doc/_docstrings/set_style.ipynb | 2 +- doc/_docstrings/set_theme.ipynb | 2 +- doc/_docstrings/stripplot.ipynb | 2 +- doc/_docstrings/swarmplot.ipynb | 2 +- doc/_docstrings/violinplot.ipynb | 2 +- doc/_tutorial/aesthetics.ipynb | 2 +- doc/_tutorial/axis_grids.ipynb | 2 +- doc/_tutorial/categorical.ipynb | 2 +- doc/_tutorial/color_palettes.ipynb | 2 +- doc/_tutorial/data_structure.ipynb | 2 +- doc/_tutorial/distributions.ipynb | 2 +- doc/_tutorial/error_bars.ipynb | 2 +- doc/_tutorial/function_overview.ipynb | 2 +- doc/_tutorial/introduction.ipynb | 2 +- doc/_tutorial/objects_interface.ipynb | 2 +- doc/_tutorial/properties.ipynb | 2 +- doc/_tutorial/regression.ipynb | 2 +- doc/_tutorial/relational.ipynb | 2 +- doc/api.rst | 1 + doc/whatsnew/index.rst | 1 + doc/whatsnew/v0.12.2.rst | 2 + seaborn/_core/plot.py | 12 +- seaborn/_core/properties.py | 292 ++++++++++++---------- seaborn/_core/rules.py | 24 +- seaborn/_core/scales.py | 143 +++++++++-- seaborn/objects.py | 4 +- tests/_core/test_properties.py | 44 ++-- tests/_core/test_rules.py | 13 +- tests/_core/test_scales.py | 153 ++++++++++++ 100 files changed, 592 insertions(+), 275 deletions(-) diff --git a/doc/_docstrings/FacetGrid.ipynb b/doc/_docstrings/FacetGrid.ipynb index eeb329f2ce..28af34c3c5 100644 --- a/doc/_docstrings/FacetGrid.ipynb +++ b/doc/_docstrings/FacetGrid.ipynb @@ -294,7 +294,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/JointGrid.ipynb b/doc/_docstrings/JointGrid.ipynb index ef8014aa0a..272bf3c3e7 100644 --- a/doc/_docstrings/JointGrid.ipynb +++ b/doc/_docstrings/JointGrid.ipynb @@ -236,7 +236,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/PairGrid.ipynb b/doc/_docstrings/PairGrid.ipynb index c39af330e9..1a9c897c0d 100644 --- a/doc/_docstrings/PairGrid.ipynb +++ b/doc/_docstrings/PairGrid.ipynb @@ -263,7 +263,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/axes_style.ipynb b/doc/_docstrings/axes_style.ipynb index 2dca0e87cf..7ba9aa599a 100644 --- a/doc/_docstrings/axes_style.ipynb +++ b/doc/_docstrings/axes_style.ipynb @@ -94,7 +94,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/barplot.ipynb b/doc/_docstrings/barplot.ipynb index 7b1264448d..6a7fa92f68 100644 --- a/doc/_docstrings/barplot.ipynb +++ b/doc/_docstrings/barplot.ipynb @@ -117,7 +117,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/blend_palette.ipynb b/doc/_docstrings/blend_palette.ipynb index 85f8755a1c..302f93e96b 100644 --- a/doc/_docstrings/blend_palette.ipynb +++ b/doc/_docstrings/blend_palette.ipynb @@ -95,7 +95,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/boxenplot.ipynb b/doc/_docstrings/boxenplot.ipynb index 1b2f863b63..b61e2d5630 100644 --- a/doc/_docstrings/boxenplot.ipynb +++ b/doc/_docstrings/boxenplot.ipynb @@ -122,7 +122,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/boxplot.ipynb b/doc/_docstrings/boxplot.ipynb index 098cda1ac4..5935f44c15 100644 --- a/doc/_docstrings/boxplot.ipynb +++ b/doc/_docstrings/boxplot.ipynb @@ -165,7 +165,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/catplot.ipynb b/doc/_docstrings/catplot.ipynb index ba956e6ab5..6e3ee7d06b 100644 --- a/doc/_docstrings/catplot.ipynb +++ b/doc/_docstrings/catplot.ipynb @@ -182,7 +182,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/clustermap.ipynb b/doc/_docstrings/clustermap.ipynb index 6937145881..487cec7646 100644 --- a/doc/_docstrings/clustermap.ipynb +++ b/doc/_docstrings/clustermap.ipynb @@ -176,7 +176,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/color_palette.ipynb b/doc/_docstrings/color_palette.ipynb index a0408b429a..b896c7b743 100644 --- a/doc/_docstrings/color_palette.ipynb +++ b/doc/_docstrings/color_palette.ipynb @@ -269,7 +269,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/countplot.ipynb b/doc/_docstrings/countplot.ipynb index 6205ac15c1..38b122b020 100644 --- a/doc/_docstrings/countplot.ipynb +++ b/doc/_docstrings/countplot.ipynb @@ -91,7 +91,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/cubehelix_palette.ipynb b/doc/_docstrings/cubehelix_palette.ipynb index a48aab5aed..a996b05864 100644 --- a/doc/_docstrings/cubehelix_palette.ipynb +++ b/doc/_docstrings/cubehelix_palette.ipynb @@ -221,7 +221,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/dark_palette.ipynb b/doc/_docstrings/dark_palette.ipynb index a4ed7adf43..143ce93f4e 100644 --- a/doc/_docstrings/dark_palette.ipynb +++ b/doc/_docstrings/dark_palette.ipynb @@ -131,7 +131,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/displot.ipynb b/doc/_docstrings/displot.ipynb index 1b2011fec0..9a4ae10cae 100644 --- a/doc/_docstrings/displot.ipynb +++ b/doc/_docstrings/displot.ipynb @@ -231,7 +231,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/diverging_palette.ipynb b/doc/_docstrings/diverging_palette.ipynb index c38196be98..ea2ad798bf 100644 --- a/doc/_docstrings/diverging_palette.ipynb +++ b/doc/_docstrings/diverging_palette.ipynb @@ -175,7 +175,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/ecdfplot.ipynb b/doc/_docstrings/ecdfplot.ipynb index d95e517a1d..7ddf95cfc0 100644 --- a/doc/_docstrings/ecdfplot.ipynb +++ b/doc/_docstrings/ecdfplot.ipynb @@ -134,7 +134,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/heatmap.ipynb b/doc/_docstrings/heatmap.ipynb index b4563a880e..ce5c90786c 100644 --- a/doc/_docstrings/heatmap.ipynb +++ b/doc/_docstrings/heatmap.ipynb @@ -205,7 +205,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/histplot.ipynb b/doc/_docstrings/histplot.ipynb index 308d0db15e..79b66364d4 100644 --- a/doc/_docstrings/histplot.ipynb +++ b/doc/_docstrings/histplot.ipynb @@ -475,7 +475,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/hls_palette.ipynb b/doc/_docstrings/hls_palette.ipynb index 03c95a248d..49a7db979f 100644 --- a/doc/_docstrings/hls_palette.ipynb +++ b/doc/_docstrings/hls_palette.ipynb @@ -149,7 +149,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/husl_palette.ipynb b/doc/_docstrings/husl_palette.ipynb index a933bb0496..8b48b55898 100644 --- a/doc/_docstrings/husl_palette.ipynb +++ b/doc/_docstrings/husl_palette.ipynb @@ -149,7 +149,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/jointplot.ipynb b/doc/_docstrings/jointplot.ipynb index 379b5307c8..b0b9d8f3ed 100644 --- a/doc/_docstrings/jointplot.ipynb +++ b/doc/_docstrings/jointplot.ipynb @@ -186,7 +186,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/kdeplot.ipynb b/doc/_docstrings/kdeplot.ipynb index 40693d05b4..f301c56359 100644 --- a/doc/_docstrings/kdeplot.ipynb +++ b/doc/_docstrings/kdeplot.ipynb @@ -341,7 +341,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/light_palette.ipynb b/doc/_docstrings/light_palette.ipynb index a1a830a3d9..15564b63e3 100644 --- a/doc/_docstrings/light_palette.ipynb +++ b/doc/_docstrings/light_palette.ipynb @@ -131,7 +131,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/lineplot.ipynb b/doc/_docstrings/lineplot.ipynb index 2d73607415..985440eac5 100644 --- a/doc/_docstrings/lineplot.ipynb +++ b/doc/_docstrings/lineplot.ipynb @@ -445,7 +445,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/lmplot.ipynb b/doc/_docstrings/lmplot.ipynb index c080373d40..4a5b4119b8 100644 --- a/doc/_docstrings/lmplot.ipynb +++ b/doc/_docstrings/lmplot.ipynb @@ -149,7 +149,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/move_legend.ipynb b/doc/_docstrings/move_legend.ipynb index f36848cf54..f16fcf502b 100644 --- a/doc/_docstrings/move_legend.ipynb +++ b/doc/_docstrings/move_legend.ipynb @@ -148,7 +148,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/mpl_palette.ipynb b/doc/_docstrings/mpl_palette.ipynb index d878fcc9ec..c65d4292f8 100644 --- a/doc/_docstrings/mpl_palette.ipynb +++ b/doc/_docstrings/mpl_palette.ipynb @@ -131,7 +131,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Agg.ipynb b/doc/_docstrings/objects.Agg.ipynb index beaec24854..5e640f324a 100644 --- a/doc/_docstrings/objects.Agg.ipynb +++ b/doc/_docstrings/objects.Agg.ipynb @@ -132,7 +132,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Area.ipynb b/doc/_docstrings/objects.Area.ipynb index 256b46a93f..9ee18b6e7b 100644 --- a/doc/_docstrings/objects.Area.ipynb +++ b/doc/_docstrings/objects.Area.ipynb @@ -153,7 +153,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Band.ipynb b/doc/_docstrings/objects.Band.ipynb index bcd1975847..896f96a199 100644 --- a/doc/_docstrings/objects.Band.ipynb +++ b/doc/_docstrings/objects.Band.ipynb @@ -135,7 +135,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Bar.ipynb b/doc/_docstrings/objects.Bar.ipynb index 1ca0851975..8d746252aa 100644 --- a/doc/_docstrings/objects.Bar.ipynb +++ b/doc/_docstrings/objects.Bar.ipynb @@ -178,7 +178,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Bars.ipynb b/doc/_docstrings/objects.Bars.ipynb index 71969df4b3..b6609731e2 100644 --- a/doc/_docstrings/objects.Bars.ipynb +++ b/doc/_docstrings/objects.Bars.ipynb @@ -157,7 +157,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Count.ipynb b/doc/_docstrings/objects.Count.ipynb index a509a68619..ee7af016e9 100644 --- a/doc/_docstrings/objects.Count.ipynb +++ b/doc/_docstrings/objects.Count.ipynb @@ -113,7 +113,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Dash.ipynb b/doc/_docstrings/objects.Dash.ipynb index 9bbbfaa0dd..845fbc5216 100644 --- a/doc/_docstrings/objects.Dash.ipynb +++ b/doc/_docstrings/objects.Dash.ipynb @@ -160,7 +160,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Dodge.ipynb b/doc/_docstrings/objects.Dodge.ipynb index 3f10b550fb..1b3c0e1d07 100644 --- a/doc/_docstrings/objects.Dodge.ipynb +++ b/doc/_docstrings/objects.Dodge.ipynb @@ -190,7 +190,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Dot.ipynb b/doc/_docstrings/objects.Dot.ipynb index d133c04127..2a60745320 100644 --- a/doc/_docstrings/objects.Dot.ipynb +++ b/doc/_docstrings/objects.Dot.ipynb @@ -182,7 +182,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Dots.ipynb b/doc/_docstrings/objects.Dots.ipynb index f1b3a53d2c..2576b899b2 100644 --- a/doc/_docstrings/objects.Dots.ipynb +++ b/doc/_docstrings/objects.Dots.ipynb @@ -138,7 +138,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Est.ipynb b/doc/_docstrings/objects.Est.ipynb index 74dcab26f9..3dcac462e5 100644 --- a/doc/_docstrings/objects.Est.ipynb +++ b/doc/_docstrings/objects.Est.ipynb @@ -134,7 +134,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Hist.ipynb b/doc/_docstrings/objects.Hist.ipynb index 778e0def95..93ed02ea21 100644 --- a/doc/_docstrings/objects.Hist.ipynb +++ b/doc/_docstrings/objects.Hist.ipynb @@ -223,7 +223,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Jitter.ipynb b/doc/_docstrings/objects.Jitter.ipynb index 6aa600cddd..ede8ce43c5 100644 --- a/doc/_docstrings/objects.Jitter.ipynb +++ b/doc/_docstrings/objects.Jitter.ipynb @@ -170,7 +170,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.KDE.ipynb b/doc/_docstrings/objects.KDE.ipynb index 851d882257..863a5a16ad 100644 --- a/doc/_docstrings/objects.KDE.ipynb +++ b/doc/_docstrings/objects.KDE.ipynb @@ -262,7 +262,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Line.ipynb b/doc/_docstrings/objects.Line.ipynb index bc8b8b5ec3..c0e5587f51 100644 --- a/doc/_docstrings/objects.Line.ipynb +++ b/doc/_docstrings/objects.Line.ipynb @@ -160,7 +160,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Lines.ipynb b/doc/_docstrings/objects.Lines.ipynb index 375715636d..012a5c4eb4 100644 --- a/doc/_docstrings/objects.Lines.ipynb +++ b/doc/_docstrings/objects.Lines.ipynb @@ -89,7 +89,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Norm.ipynb b/doc/_docstrings/objects.Norm.ipynb index 680df67690..dee130640c 100644 --- a/doc/_docstrings/objects.Norm.ipynb +++ b/doc/_docstrings/objects.Norm.ipynb @@ -85,7 +85,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Path.ipynb b/doc/_docstrings/objects.Path.ipynb index 39b4a2b78a..6ec364ff94 100644 --- a/doc/_docstrings/objects.Path.ipynb +++ b/doc/_docstrings/objects.Path.ipynb @@ -78,7 +78,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Paths.ipynb b/doc/_docstrings/objects.Paths.ipynb index 5d9d33990e..5f326bf07a 100644 --- a/doc/_docstrings/objects.Paths.ipynb +++ b/doc/_docstrings/objects.Paths.ipynb @@ -95,7 +95,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Perc.ipynb b/doc/_docstrings/objects.Perc.ipynb index b97c87cc1f..d1c8094aea 100644 --- a/doc/_docstrings/objects.Perc.ipynb +++ b/doc/_docstrings/objects.Perc.ipynb @@ -122,7 +122,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Plot.add.ipynb b/doc/_docstrings/objects.Plot.add.ipynb index e997aca980..365f189143 100644 --- a/doc/_docstrings/objects.Plot.add.ipynb +++ b/doc/_docstrings/objects.Plot.add.ipynb @@ -210,7 +210,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Plot.facet.ipynb b/doc/_docstrings/objects.Plot.facet.ipynb index 2155dfb5ec..c8a7d1d769 100644 --- a/doc/_docstrings/objects.Plot.facet.ipynb +++ b/doc/_docstrings/objects.Plot.facet.ipynb @@ -214,7 +214,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Plot.label.ipynb b/doc/_docstrings/objects.Plot.label.ipynb index 3a4300b3e3..1497c56504 100644 --- a/doc/_docstrings/objects.Plot.label.ipynb +++ b/doc/_docstrings/objects.Plot.label.ipynb @@ -153,7 +153,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Plot.layout.ipynb b/doc/_docstrings/objects.Plot.layout.ipynb index 1198766365..755d6d3a28 100644 --- a/doc/_docstrings/objects.Plot.layout.ipynb +++ b/doc/_docstrings/objects.Plot.layout.ipynb @@ -94,7 +94,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Plot.limit.ipynb b/doc/_docstrings/objects.Plot.limit.ipynb index 9d8f33cfa7..6d1ec6084d 100644 --- a/doc/_docstrings/objects.Plot.limit.ipynb +++ b/doc/_docstrings/objects.Plot.limit.ipynb @@ -112,7 +112,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Plot.on.ipynb b/doc/_docstrings/objects.Plot.on.ipynb index 7e14557bc0..f297bf631f 100644 --- a/doc/_docstrings/objects.Plot.on.ipynb +++ b/doc/_docstrings/objects.Plot.on.ipynb @@ -174,7 +174,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Plot.pair.ipynb b/doc/_docstrings/objects.Plot.pair.ipynb index fc78cbf175..c31240f57f 100644 --- a/doc/_docstrings/objects.Plot.pair.ipynb +++ b/doc/_docstrings/objects.Plot.pair.ipynb @@ -209,7 +209,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Plot.scale.ipynb b/doc/_docstrings/objects.Plot.scale.ipynb index d2d679f429..b4c11680ec 100644 --- a/doc/_docstrings/objects.Plot.scale.ipynb +++ b/doc/_docstrings/objects.Plot.scale.ipynb @@ -308,7 +308,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Plot.share.ipynb b/doc/_docstrings/objects.Plot.share.ipynb index d0b1ef5cb1..097cf01bd0 100644 --- a/doc/_docstrings/objects.Plot.share.ipynb +++ b/doc/_docstrings/objects.Plot.share.ipynb @@ -123,7 +123,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Plot.theme.ipynb b/doc/_docstrings/objects.Plot.theme.ipynb index bb459a5620..46f22f51df 100644 --- a/doc/_docstrings/objects.Plot.theme.ipynb +++ b/doc/_docstrings/objects.Plot.theme.ipynb @@ -139,7 +139,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Range.ipynb b/doc/_docstrings/objects.Range.ipynb index cccb2296ed..3e462255fb 100644 --- a/doc/_docstrings/objects.Range.ipynb +++ b/doc/_docstrings/objects.Range.ipynb @@ -132,7 +132,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Shift.ipynb b/doc/_docstrings/objects.Shift.ipynb index e1e51db2d5..e33c90c959 100644 --- a/doc/_docstrings/objects.Shift.ipynb +++ b/doc/_docstrings/objects.Shift.ipynb @@ -86,7 +86,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Stack.ipynb b/doc/_docstrings/objects.Stack.ipynb index 2caf8d3e70..7878db9a6a 100644 --- a/doc/_docstrings/objects.Stack.ipynb +++ b/doc/_docstrings/objects.Stack.ipynb @@ -81,7 +81,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/objects.Text.ipynb b/doc/_docstrings/objects.Text.ipynb index 10bfdc5db5..4d8f3204af 100644 --- a/doc/_docstrings/objects.Text.ipynb +++ b/doc/_docstrings/objects.Text.ipynb @@ -180,7 +180,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/pairplot.ipynb b/doc/_docstrings/pairplot.ipynb index 67948e4f9b..7aa8d45b86 100644 --- a/doc/_docstrings/pairplot.ipynb +++ b/doc/_docstrings/pairplot.ipynb @@ -217,7 +217,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/plotting_context.ipynb b/doc/_docstrings/plotting_context.ipynb index 4f757331a8..43009c2aa7 100644 --- a/doc/_docstrings/plotting_context.ipynb +++ b/doc/_docstrings/plotting_context.ipynb @@ -102,7 +102,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/pointplot.ipynb b/doc/_docstrings/pointplot.ipynb index e58aeec19a..9c227fcc37 100644 --- a/doc/_docstrings/pointplot.ipynb +++ b/doc/_docstrings/pointplot.ipynb @@ -134,7 +134,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/regplot.ipynb b/doc/_docstrings/regplot.ipynb index 2a8f102091..2b1ef937a6 100644 --- a/doc/_docstrings/regplot.ipynb +++ b/doc/_docstrings/regplot.ipynb @@ -243,7 +243,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/relplot.ipynb b/doc/_docstrings/relplot.ipynb index a7b36f1a44..42ef09a324 100644 --- a/doc/_docstrings/relplot.ipynb +++ b/doc/_docstrings/relplot.ipynb @@ -254,7 +254,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/residplot.ipynb b/doc/_docstrings/residplot.ipynb index 3dd26d0168..287462f2e0 100644 --- a/doc/_docstrings/residplot.ipynb +++ b/doc/_docstrings/residplot.ipynb @@ -105,7 +105,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/rugplot.ipynb b/doc/_docstrings/rugplot.ipynb index 4092dab06b..ce5da483c2 100644 --- a/doc/_docstrings/rugplot.ipynb +++ b/doc/_docstrings/rugplot.ipynb @@ -129,7 +129,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/scatterplot.ipynb b/doc/_docstrings/scatterplot.ipynb index 315a54845b..973a67d690 100644 --- a/doc/_docstrings/scatterplot.ipynb +++ b/doc/_docstrings/scatterplot.ipynb @@ -299,7 +299,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/set_context.ipynb b/doc/_docstrings/set_context.ipynb index 07a5c091d4..97c8679cb7 100644 --- a/doc/_docstrings/set_context.ipynb +++ b/doc/_docstrings/set_context.ipynb @@ -96,7 +96,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/set_style.ipynb b/doc/_docstrings/set_style.ipynb index 25cdde23d6..7780bcf95a 100644 --- a/doc/_docstrings/set_style.ipynb +++ b/doc/_docstrings/set_style.ipynb @@ -77,7 +77,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/set_theme.ipynb b/doc/_docstrings/set_theme.ipynb index add6eb2886..c2820ab9cd 100644 --- a/doc/_docstrings/set_theme.ipynb +++ b/doc/_docstrings/set_theme.ipynb @@ -153,7 +153,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/stripplot.ipynb b/doc/_docstrings/stripplot.ipynb index d33034b5ba..386ad117fd 100644 --- a/doc/_docstrings/stripplot.ipynb +++ b/doc/_docstrings/stripplot.ipynb @@ -305,7 +305,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/swarmplot.ipynb b/doc/_docstrings/swarmplot.ipynb index e90ee52115..c3341c5172 100644 --- a/doc/_docstrings/swarmplot.ipynb +++ b/doc/_docstrings/swarmplot.ipynb @@ -277,7 +277,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_docstrings/violinplot.ipynb b/doc/_docstrings/violinplot.ipynb index ebf5c4d963..35e1246672 100644 --- a/doc/_docstrings/violinplot.ipynb +++ b/doc/_docstrings/violinplot.ipynb @@ -185,7 +185,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/aesthetics.ipynb b/doc/_tutorial/aesthetics.ipynb index 46f55d83ac..63f8198779 100644 --- a/doc/_tutorial/aesthetics.ipynb +++ b/doc/_tutorial/aesthetics.ipynb @@ -418,7 +418,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/axis_grids.ipynb b/doc/_tutorial/axis_grids.ipynb index 4a652d6c00..da3cc587e5 100644 --- a/doc/_tutorial/axis_grids.ipynb +++ b/doc/_tutorial/axis_grids.ipynb @@ -545,7 +545,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/categorical.ipynb b/doc/_tutorial/categorical.ipynb index a1faa86a4f..77f6527b56 100644 --- a/doc/_tutorial/categorical.ipynb +++ b/doc/_tutorial/categorical.ipynb @@ -534,7 +534,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/color_palettes.ipynb b/doc/_tutorial/color_palettes.ipynb index 48cb84640f..5b43e766c0 100644 --- a/doc/_tutorial/color_palettes.ipynb +++ b/doc/_tutorial/color_palettes.ipynb @@ -996,7 +996,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/data_structure.ipynb b/doc/_tutorial/data_structure.ipynb index be7d55a026..7fda56b6ec 100644 --- a/doc/_tutorial/data_structure.ipynb +++ b/doc/_tutorial/data_structure.ipynb @@ -489,7 +489,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/distributions.ipynb b/doc/_tutorial/distributions.ipynb index 7e47442b49..1ae80838b9 100644 --- a/doc/_tutorial/distributions.ipynb +++ b/doc/_tutorial/distributions.ipynb @@ -850,7 +850,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/error_bars.ipynb b/doc/_tutorial/error_bars.ipynb index 1d34b35c75..f101a80edf 100644 --- a/doc/_tutorial/error_bars.ipynb +++ b/doc/_tutorial/error_bars.ipynb @@ -361,7 +361,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/function_overview.ipynb b/doc/_tutorial/function_overview.ipynb index 5096ca02e4..3648504cf5 100644 --- a/doc/_tutorial/function_overview.ipynb +++ b/doc/_tutorial/function_overview.ipynb @@ -488,7 +488,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/introduction.ipynb b/doc/_tutorial/introduction.ipynb index c3f74f0fcf..37792610a1 100644 --- a/doc/_tutorial/introduction.ipynb +++ b/doc/_tutorial/introduction.ipynb @@ -461,7 +461,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/objects_interface.ipynb b/doc/_tutorial/objects_interface.ipynb index d5a0700ef1..306875272f 100644 --- a/doc/_tutorial/objects_interface.ipynb +++ b/doc/_tutorial/objects_interface.ipynb @@ -1071,7 +1071,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/properties.ipynb b/doc/_tutorial/properties.ipynb index acfd7fad61..70de0e9ea2 100644 --- a/doc/_tutorial/properties.ipynb +++ b/doc/_tutorial/properties.ipynb @@ -1119,7 +1119,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/regression.ipynb b/doc/_tutorial/regression.ipynb index 91ff460eb3..d957101e07 100644 --- a/doc/_tutorial/regression.ipynb +++ b/doc/_tutorial/regression.ipynb @@ -446,7 +446,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/_tutorial/relational.ipynb b/doc/_tutorial/relational.ipynb index 4d8fb5f6b1..f96ed638df 100644 --- a/doc/_tutorial/relational.ipynb +++ b/doc/_tutorial/relational.ipynb @@ -677,7 +677,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.0" + "version": "3.10.6" } }, "nbformat": 4, diff --git a/doc/api.rst b/doc/api.rst index c82bdcaa4a..189e790467 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -114,6 +114,7 @@ Scale objects :template: scale :nosignatures: + Boolean Continuous Nominal Temporal diff --git a/doc/whatsnew/index.rst b/doc/whatsnew/index.rst index 82b2a9aed7..8fb4e3ff97 100644 --- a/doc/whatsnew/index.rst +++ b/doc/whatsnew/index.rst @@ -8,6 +8,7 @@ v0.12 .. toctree:: :maxdepth: 2 + v0.12.2 v0.12.1 v0.12.0 diff --git a/doc/whatsnew/v0.12.2.rst b/doc/whatsnew/v0.12.2.rst index 423cc58e5c..1daf67868b 100644 --- a/doc/whatsnew/v0.12.2.rst +++ b/doc/whatsnew/v0.12.2.rst @@ -4,6 +4,8 @@ v0.12.2 (Unreleased) - |Feature| Added the :class:`objects.KDE` stat (:pr:`3111`). +- |Feature| Added the :class:`objects.Boolean` scale (:pr:`3205`). + - |Enhancement| Automatic mark widths are now calculated separately for unshared facet axes (:pr:`3119`). - |Enhancement| Improved user feedback for failures during plot compilation by catching exceptions an reraising with a `PlotSpecError` that provides additional context (:pr:`3203`). diff --git a/seaborn/_core/plot.py b/seaborn/_core/plot.py index 6c7202c643..93bed6f82c 100644 --- a/seaborn/_core/plot.py +++ b/seaborn/_core/plot.py @@ -1659,16 +1659,8 @@ def _finalize_figure(self, p: Plot) -> None: hi = cast(float, hi) + 0.5 ax.set(**{f"{axis}lim": (lo, hi)}) - # Nominal scale special-casing - if isinstance(self._scales.get(axis_key), Nominal): - axis_obj.grid(False, which="both") - if axis_key not in p._limits: - nticks = len(axis_obj.get_major_ticks()) - lo, hi = -.5, nticks - .5 - if axis == "y": - lo, hi = hi, lo - set_lim = getattr(ax, f"set_{axis}lim") - set_lim(lo, hi, auto=None) + if axis_key in self._scales: # TODO when would it not be? + self._scales[axis_key]._finalize(p, axis_obj) engine_default = None if p._target is not None else "tight" layout_engine = p._layout_spec.get("engine", engine_default) diff --git a/seaborn/_core/properties.py b/seaborn/_core/properties.py index cd10e260ef..f2d7c21eb1 100644 --- a/seaborn/_core/properties.py +++ b/seaborn/_core/properties.py @@ -8,7 +8,7 @@ from matplotlib.colors import to_rgb, to_rgba, to_rgba_array from matplotlib.path import Path -from seaborn._core.scales import Scale, Nominal, Continuous, Temporal +from seaborn._core.scales import Scale, Boolean, Continuous, Nominal, Temporal from seaborn._core.rules import categorical_order, variable_type from seaborn._compat import MarkerStyle from seaborn.palettes import QUAL_PALETTES, color_palette, blend_palette @@ -38,6 +38,8 @@ MarkerStyle, ] +Mapping = Callable[[ArrayLike], ArrayLike] + # =================================================================================== # # Base classes @@ -61,17 +63,14 @@ def __init__(self, variable: str | None = None): def default_scale(self, data: Series) -> Scale: """Given data, initialize appropriate scale class.""" - # TODO allow variable_type to be "boolean" if that's a scale? - # TODO how will this handle data with units that can be treated as numeric - # if passed through a registered matplotlib converter? - var_type = variable_type(data, boolean_type="numeric") + + var_type = variable_type(data, boolean_type="boolean", strict_boolean=True) if var_type == "numeric": return Continuous() elif var_type == "datetime": return Temporal() - # TODO others - # time-based (TimeStamp, TimeDelta, Period) - # boolean scale? + elif var_type == "boolean": + return Boolean() else: return Nominal() @@ -95,9 +94,7 @@ def infer_scale(self, arg: Any, data: Series) -> Scale: msg = f"Magic arg for {self.variable} scale must be str, not {arg_type}." raise TypeError(msg) - def get_mapping( - self, scale: Scale, data: Series - ) -> Callable[[ArrayLike], ArrayLike]: + def get_mapping(self, scale: Scale, data: Series) -> Mapping: """Return a function that maps from data domain to property range.""" def identity(x): return x @@ -181,22 +178,26 @@ def infer_scale(self, arg: Any, data: Series) -> Scale: # TODO infer continuous based on log/sqrt etc? - if isinstance(arg, (list, dict)): + var_type = variable_type(data, boolean_type="boolean", strict_boolean=True) + + if var_type == "boolean": + return Boolean(arg) + elif isinstance(arg, (list, dict)): return Nominal(arg) - elif variable_type(data) == "categorical": + elif var_type == "categorical": return Nominal(arg) - elif variable_type(data) == "datetime": + elif var_type == "datetime": return Temporal(arg) # TODO other variable types else: return Continuous(arg) - def get_mapping( - self, scale: Scale, data: ArrayLike - ) -> Callable[[ArrayLike], ArrayLike]: + def get_mapping(self, scale: Scale, data: Series) -> Mapping: """Return a function that maps from data domain to property range.""" if isinstance(scale, Nominal): - return self._get_categorical_mapping(scale, data) + return self._get_nominal_mapping(scale, data) + elif isinstance(scale, Boolean): + return self._get_boolean_mapping(scale, data) if scale.values is None: vmin, vmax = self._forward(self.default_range) @@ -219,12 +220,34 @@ def mapping(x): return mapping - def _get_categorical_mapping( - self, scale: Nominal, data: ArrayLike - ) -> Callable[[ArrayLike], ArrayLike]: + def _get_nominal_mapping(self, scale: Nominal, data: Series) -> Mapping: """Identify evenly-spaced values using interval or explicit mapping.""" levels = categorical_order(data, scale.order) + values = self._get_values(scale, levels) + + def mapping(x): + ixs = np.asarray(x, np.intp) + out = np.full(len(x), np.nan) + use = np.isfinite(x) + out[use] = np.take(values, ixs[use]) + return out + + return mapping + + def _get_boolean_mapping(self, scale: Boolean, data: Series) -> Mapping: + """Identify evenly-spaced values using interval or explicit mapping.""" + values = self._get_values(scale, [True, False]) + + def mapping(x): + out = np.full(len(x), np.nan) + use = np.isfinite(x) + out[use] = np.where(x[use], *values) + return out + + return mapping + def _get_values(self, scale: Scale, levels: list) -> list: + """Validate scale.values and identify a value for each level.""" if isinstance(scale.values, dict): self._check_dict_entries(levels, scale.values) values = [scale.values[x] for x in levels] @@ -244,16 +267,9 @@ def _get_categorical_mapping( raise TypeError(err) vmin, vmax = self._forward([vmin, vmax]) - values = self._inverse(np.linspace(vmax, vmin, len(levels))) + values = list(self._inverse(np.linspace(vmax, vmin, len(levels)))) - def mapping(x): - ixs = np.asarray(x, np.intp) - out = np.full(len(x), np.nan) - use = np.isfinite(x) - out[use] = np.take(values, ixs[use]) - return out - - return mapping + return values class PointSize(IntervalProperty): @@ -332,20 +348,36 @@ class ObjectProperty(Property): def _default_values(self, n: int) -> list: raise NotImplementedError() - def default_scale(self, data: Series) -> Nominal: - return Nominal() + def default_scale(self, data: Series) -> Scale: + var_type = variable_type(data, boolean_type="boolean", strict_boolean=True) + return Boolean() if var_type == "boolean" else Nominal() - def infer_scale(self, arg: Any, data: Series) -> Nominal: - return Nominal(arg) + def infer_scale(self, arg: Any, data: Series) -> Scale: + var_type = variable_type(data, boolean_type="boolean", strict_boolean=True) + return Boolean(arg) if var_type == "boolean" else Nominal(arg) - def get_mapping( - self, scale: Scale, data: Series, - ) -> Callable[[ArrayLike], list]: + def get_mapping(self, scale: Scale, data: Series) -> Mapping: """Define mapping as lookup into list of object values.""" - order = getattr(scale, "order", None) + boolean_scale = isinstance(scale, Boolean) + order = getattr(scale, "order", [True, False] if boolean_scale else None) levels = categorical_order(data, order) - n = len(levels) + values = self._get_values(scale, levels) + + if boolean_scale: + values = values[::-1] + + def mapping(x): + ixs = np.asarray(x, np.intp) + return [ + values[ix] if np.isfinite(x_i) else self.null_value + for x_i, ix in zip(x, ixs) + ] + return mapping + + def _get_values(self, scale: Scale, levels: list) -> list: + """Validate scale.values and identify a value for each level.""" + n = len(levels) if isinstance(scale.values, dict): self._check_dict_entries(levels, scale.values) values = [scale.values[x] for x in levels] @@ -361,15 +393,7 @@ def get_mapping( raise TypeError(msg) values = [self.standardize(x) for x in values] - - def mapping(x): - ixs = np.asarray(x, np.intp) - return [ - values[ix] if np.isfinite(x_i) else self.null_value - for x_i, ix in zip(x, ixs) - ] - - return mapping + return values class Marker(ObjectProperty): @@ -569,7 +593,10 @@ def infer_scale(self, arg: Any, data: Series) -> Scale: # TODO need to rethink the variable type system # (e.g. boolean, ordered categories as Ordinal, etc).. - var_type = variable_type(data, boolean_type="categorical") + var_type = variable_type(data, boolean_type="boolean", strict_boolean=True) + + if var_type == "boolean": + return Boolean(arg) if isinstance(arg, (dict, list)): return Nominal(arg) @@ -587,10 +614,6 @@ def infer_scale(self, arg: Any, data: Series) -> Scale: # TODO Do we accept str like "log", "pow", etc. for semantics? - # TODO what about - # - Temporal? (i.e. datetime) - # - Boolean? - if not isinstance(arg, str): msg = " ".join([ f"A single scale argument for {self.variable} variables must be", @@ -606,56 +629,14 @@ def infer_scale(self, arg: Any, data: Series) -> Scale: else: return Nominal(arg) - def _get_categorical_mapping(self, scale, data): - """Define mapping as lookup in list of discrete color values.""" - levels = categorical_order(data, scale.order) - n = len(levels) - values = scale.values - - if isinstance(values, dict): - self._check_dict_entries(levels, values) - # TODO where to ensure that dict values have consistent representation? - colors = [values[x] for x in levels] - elif isinstance(values, list): - colors = self._check_list_length(levels, scale.values) - elif isinstance(values, tuple): - colors = blend_palette(values, n) - elif isinstance(values, str): - colors = color_palette(values, n) - elif values is None: - if n <= len(get_color_cycle()): - # Use current (global) default palette - colors = color_palette(n_colors=n) - else: - colors = color_palette("husl", n) - else: - scale_class = scale.__class__.__name__ - msg = " ".join([ - f"Scale values for {self.variable} with a {scale_class} mapping", - f"must be string, list, tuple, or dict; not {type(scale.values)}." - ]) - raise TypeError(msg) - - # If color specified here has alpha channel, it will override alpha property - colors = self._standardize_color_sequence(colors) - - def mapping(x): - ixs = np.asarray(x, np.intp) - use = np.isfinite(x) - out = np.full((len(ixs), colors.shape[1]), np.nan) - out[use] = np.take(colors, ixs[use], axis=0) - return out - - return mapping - - def get_mapping( - self, scale: Scale, data: Series - ) -> Callable[[ArrayLike], ArrayLike]: + def get_mapping(self, scale: Scale, data: Series) -> Mapping: """Return a function that maps from data domain to color values.""" # TODO what is best way to do this conditional? # Should it be class-based or should classes have behavioral attributes? if isinstance(scale, Nominal): - return self._get_categorical_mapping(scale, data) + return self._get_nominal_mapping(scale, data) + elif isinstance(scale, Boolean): + return self._get_boolean_mapping(scale, data) if scale.values is None: # TODO Rethink best default continuous color gradient @@ -689,6 +670,64 @@ def _mapping(x): return _mapping + def _get_nominal_mapping(self, scale: Nominal, data: Series) -> Mapping: + + levels = categorical_order(data, scale.order) + colors = self._get_values(scale, levels) + + def mapping(x): + ixs = np.asarray(x, np.intp) + use = np.isfinite(x) + out = np.full((len(ixs), colors.shape[1]), np.nan) + out[use] = np.take(colors, ixs[use], axis=0) + return out + + return mapping + + def _get_boolean_mapping(self, scale: Boolean, data: Series) -> Mapping: + + colors = self._get_values(scale, [True, False]) + + def mapping(x): + + use = np.isfinite(x) + x = np.asarray(x).astype(bool) + out = np.full((len(x), colors.shape[1]), np.nan) + out[x & use] = colors[0] + out[~x & use] = colors[1] + return out + + return mapping + + def _get_values(self, scale: Scale, levels: list) -> ArrayLike: + """Validate scale.values and identify a value for each level.""" + n = len(levels) + values = scale.values + if isinstance(values, dict): + self._check_dict_entries(levels, values) + colors = [values[x] for x in levels] + elif isinstance(values, list): + colors = self._check_list_length(levels, values) + elif isinstance(values, tuple): + colors = blend_palette(values, n) + elif isinstance(values, str): + colors = color_palette(values, n) + elif values is None: + if n <= len(get_color_cycle()): + # Use current (global) default palette + colors = color_palette(n_colors=n) + else: + colors = color_palette("husl", n) + else: + scale_class = scale.__class__.__name__ + msg = " ".join([ + f"Scale values for {self.variable} with a {scale_class} mapping", + f"must be string, list, tuple, or dict; not {type(scale.values)}." + ]) + raise TypeError(msg) + + return self._standardize_color_sequence(colors) + # =================================================================================== # # Properties that can take only two states @@ -700,9 +739,13 @@ class Fill(Property): legend = True normed = False - # TODO default to Nominal scale always? - # Actually this will just not work with Continuous (except 0/1), suggesting we need - # an abstraction for failing gracefully on bad Property <> Scale interactions + def default_scale(self, data: Series) -> Scale: + var_type = variable_type(data, boolean_type="boolean", strict_boolean=True) + return Boolean() if var_type == "boolean" else Nominal() + + def infer_scale(self, arg: Any, data: Series) -> Scale: + var_type = variable_type(data, boolean_type="boolean", strict_boolean=True) + return Boolean(arg) if var_type == "boolean" else Nominal(arg) def standardize(self, val: Any) -> bool: return bool(val) @@ -718,27 +761,27 @@ def _default_values(self, n: int) -> list: warnings.warn(msg, UserWarning) return [x for x, _ in zip(itertools.cycle([True, False]), range(n))] - def default_scale(self, data: Series) -> Nominal: - """Given data, initialize appropriate scale class.""" - return Nominal() - - def infer_scale(self, arg: Any, data: Series) -> Scale: - """Given data and a scaling argument, initialize appropriate scale class.""" - # TODO infer Boolean where possible? - return Nominal(arg) - - def get_mapping( - self, scale: Scale, data: Series - ) -> Callable[[ArrayLike], ArrayLike]: + def get_mapping(self, scale: Scale, data: Series) -> Mapping: """Return a function that maps each data value to True or False.""" - # TODO categorical_order is going to return [False, True] for booleans, - # and [0, 1] for binary, but the default values order is [True, False]. - # We should special case this to handle it properly, or change - # categorical_order to not "sort" booleans. Note that we need to sync with - # what's going to happen upstream in the scale, so we can't just do it here. - order = getattr(scale, "order", None) + boolean_scale = isinstance(scale, Boolean) + order = getattr(scale, "order", [True, False] if boolean_scale else None) levels = categorical_order(data, order) + values = self._get_values(scale, levels) + + if boolean_scale: + values = values[::-1] + + def mapping(x): + ixs = np.asarray(x, np.intp) + return [ + values[ix] if np.isfinite(x_i) else False + for x_i, ix in zip(x, ixs) + ] + + return mapping + def _get_values(self, scale: Scale, levels: list) -> list: + """Validate scale.values and identify a value for each level.""" if isinstance(scale.values, list): values = [bool(x) for x in scale.values] elif isinstance(scale.values, dict): @@ -752,14 +795,7 @@ def get_mapping( ]) raise TypeError(msg) - def mapping(x): - ixs = np.asarray(x, np.intp) - return [ - values[ix] if np.isfinite(x_i) else False - for x_i, ix in zip(x, ixs) - ] - - return mapping + return values # =================================================================================== # diff --git a/seaborn/_core/rules.py b/seaborn/_core/rules.py index fea910342b..3b3702d322 100644 --- a/seaborn/_core/rules.py +++ b/seaborn/_core/rules.py @@ -8,6 +8,8 @@ import numpy as np import pandas as pd +from seaborn.external.version import Version + from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Literal @@ -24,7 +26,7 @@ class VarType(UserString): """ # TODO VarType is an awfully overloaded name, but so is DataType ... # TODO adding unknown because we are using this in for scales, is that right? - allowed = "numeric", "datetime", "categorical", "unknown" + allowed = "numeric", "datetime", "categorical", "boolean", "unknown" def __init__(self, data): assert data in self.allowed, data @@ -37,24 +39,28 @@ def __eq__(self, other): def variable_type( vector: Series, - boolean_type: Literal["numeric", "categorical"] = "numeric", + boolean_type: Literal["numeric", "categorical", "boolean"] = "numeric", + strict_boolean: bool = False, ) -> VarType: """ Determine whether a vector contains numeric, categorical, or datetime data. - This function differs from the pandas typing API in two ways: + This function differs from the pandas typing API in a few ways: - Python sequences or object-typed PyData objects are considered numeric if all of their entries are numeric. - String or mixed-type data are considered categorical even if not explicitly represented as a :class:`pandas.api.types.CategoricalDtype`. + - There is some flexibility about how to treat binary / boolean data. Parameters ---------- vector : :func:`pandas.Series`, :func:`numpy.ndarray`, or Python sequence Input data to test. - boolean_type : 'numeric' or 'categorical' + boolean_type : 'numeric', 'categorical', or 'boolean' Type to use for vectors containing only 0s and 1s (and NAs). + strict_boolean : bool + If True, only consider data to be boolean when the dtype is bool or Boolean. Returns ------- @@ -83,7 +89,15 @@ def variable_type( action='ignore', category=(FutureWarning, DeprecationWarning) # type: ignore # mypy bug? ) - if np.isin(vector, [0, 1, np.nan]).all(): + if strict_boolean: + if Version(pd.__version__) < Version("1.0.0"): + boolean_dtypes = ["bool"] + else: + boolean_dtypes = ["bool", "boolean"] + boolean_vector = vector.dtype in boolean_dtypes + else: + boolean_vector = bool(np.isin(vector, [0, 1, np.nan]).all()) + if boolean_vector: return VarType(boolean_type) # Defer to positive pandas tests diff --git a/seaborn/_core/scales.py b/seaborn/_core/scales.py index 3c215a60d8..acba3201c3 100644 --- a/seaborn/_core/scales.py +++ b/seaborn/_core/scales.py @@ -39,6 +39,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from seaborn._core.plot import Plot from seaborn._core.properties import Property from numpy.typing import ArrayLike, NDArray @@ -60,7 +61,7 @@ class Scale: _pipeline: Pipeline _matplotlib_scale: ScaleBase _spacer: staticmethod - _legend: tuple[list[str], list[Any]] | None + _legend: tuple[list[Any], list[str]] | None def __post_init__(self): @@ -107,6 +108,10 @@ def _setup( ) -> Scale: raise NotImplementedError() + def _finalize(self, p: Plot, axis: Axis) -> None: + """Perform scale-specific axis tweaks after adding artists.""" + pass + def __call__(self, data: Series) -> ArrayLike: trans_data: Series | NDArray | list @@ -140,6 +145,99 @@ class Identity(Scale): return Identity() +@dataclass +class Boolean(Scale): + """ + A scale with a discrete domain of True and False values. + + The behavior is similar to the :class:`Nominal` scale, but property + mappings and legends will use a [True, False] ordering rather than + a sort using numeric rules. Coordinate variables accomplish this by + inverting axis limits so as to maintain underlying numeric positioning. + Input data are cast to boolean values, respecting missing data. + + """ + values: tuple | list | dict | None = None + + _priority: ClassVar[int] = 3 + + def _setup( + self, data: Series, prop: Property, axis: Axis | None = None, + ) -> Scale: + + new = copy(self) + if new._tick_params is None: + new = new.tick() + if new._label_params is None: + new = new.label() + + def na_safe_cast(x): + # TODO this doesn't actually need to be a closure + if np.isscalar(x): + return float(bool(x)) + else: + if hasattr(x, "notna"): + # Handle pd.NA; np<>pd interop with NA is tricky + use = x.notna().to_numpy() + else: + use = np.isfinite(x) + out = np.full(len(x), np.nan, dtype=float) + out[use] = x[use].astype(bool).astype(float) + return out + + new._pipeline = [na_safe_cast, prop.get_mapping(new, data)] + new._spacer = _default_spacer + if prop.legend: + new._legend = [True, False], ["True", "False"] + + forward, inverse = _make_identity_transforms() + mpl_scale = new._get_scale(str(data.name), forward, inverse) + + axis = PseudoAxis(mpl_scale) if axis is None else axis + mpl_scale.set_default_locators_and_formatters(axis) + new._matplotlib_scale = mpl_scale + + return new + + def _finalize(self, p: Plot, axis: Axis) -> None: + + # We want values to appear in a True, False order but also want + # True/False to be drawn at 1/0 positions respectively to avoid nasty + # surprises if additional artists are added through the matplotlib API. + # We accomplish this using axis inversion akin to what we do in Nominal. + + ax = axis.axes + name = axis.axis_name + axis.grid(False, which="both") + if name not in p._limits: + nticks = len(axis.get_major_ticks()) + lo, hi = -.5, nticks - .5 + if name == "x": + lo, hi = hi, lo + set_lim = getattr(ax, f"set_{name}lim") + set_lim(lo, hi, auto=None) + + def tick(self, locator: Locator | None = None): + new = copy(self) + new._tick_params = {"locator": locator} + return new + + def label(self, formatter: Formatter | None = None): + new = copy(self) + new._label_params = {"formatter": formatter} + return new + + def _get_locators(self, locator): + if locator is not None: + return locator + return FixedLocator([0, 1]), None + + def _get_formatter(self, locator, formatter): + if formatter is not None: + return formatter + return FuncFormatter(lambda x, _: str(bool(x))) + + @dataclass class Nominal(Scale): """ @@ -150,7 +248,7 @@ class Nominal(Scale): values: tuple | str | list | dict | None = None order: list | None = None - _priority: ClassVar[int] = 3 + _priority: ClassVar[int] = 4 def _setup( self, data: Series, prop: Property, axis: Axis | None = None, @@ -217,23 +315,28 @@ def convert_units(x): out[keep] = axis.convert_units(stringify(x[keep])) return out - new._pipeline = [ - convert_units, - prop.get_mapping(new, data), - # TODO how to handle color representation consistency? - ] - - def spacer(x): - return 1 - - new._spacer = spacer + new._pipeline = [convert_units, prop.get_mapping(new, data)] + new._spacer = _default_spacer if prop.legend: new._legend = units_seed, list(stringify(units_seed)) return new - def tick(self, locator: Locator | None = None): + def _finalize(self, p: Plot, axis: Axis) -> None: + + ax = axis.axes + name = axis.axis_name + axis.grid(False, which="both") + if name not in p._limits: + nticks = len(axis.get_major_ticks()) + lo, hi = -.5, nticks - .5 + if name == "y": + lo, hi = hi, lo + set_lim = getattr(ax, f"set_{name}lim") + set_lim(lo, hi, auto=None) + + def tick(self, locator: Locator | None = None) -> Nominal: """ Configure the selection of ticks for the scale's axis or legend. @@ -252,12 +355,10 @@ def tick(self, locator: Locator | None = None): """ new = copy(self) - new._tick_params = { - "locator": locator, - } + new._tick_params = {"locator": locator} return new - def label(self, formatter: Formatter | None = None): + def label(self, formatter: Formatter | None = None) -> Nominal: """ Configure the selection of labels for the scale's axis or legend. @@ -277,9 +378,7 @@ def label(self, formatter: Formatter | None = None): """ new = copy(self) - new._label_params = { - "formatter": formatter, - } + new._label_params = {"formatter": formatter} return new def _get_locators(self, locator): @@ -986,3 +1085,7 @@ def inverse(x): return np.sign(x) * np.power(np.abs(x), 1 / exp) return forward, inverse + + +def _default_spacer(x: Series) -> float: + return 1 diff --git a/seaborn/objects.py b/seaborn/objects.py index 27e4c38155..123e57f0a9 100644 --- a/seaborn/objects.py +++ b/seaborn/objects.py @@ -44,4 +44,6 @@ from seaborn._core.moves import Dodge, Jitter, Norm, Shift, Stack, Move # noqa: F401 -from seaborn._core.scales import Nominal, Continuous, Temporal, Scale # noqa: F401 +from seaborn._core.scales import ( # noqa: F401 + Boolean, Continuous, Nominal, Temporal, Scale +) diff --git a/tests/_core/test_properties.py b/tests/_core/test_properties.py index efef33c351..4a4d26caa7 100644 --- a/tests/_core/test_properties.py +++ b/tests/_core/test_properties.py @@ -9,7 +9,7 @@ from seaborn.external.version import Version from seaborn._core.rules import categorical_order -from seaborn._core.scales import Nominal, Continuous +from seaborn._core.scales import Nominal, Continuous, Boolean from seaborn._core.properties import ( Alpha, Color, @@ -52,8 +52,12 @@ def dt_cat_vector(self, long_df): return long_df["d"] @pytest.fixture - def vectors(self, num_vector, cat_vector): - return {"num": num_vector, "cat": cat_vector} + def bool_vector(self, long_df): + return long_df["x"] > 10 + + @pytest.fixture + def vectors(self, num_vector, cat_vector, bool_vector): + return {"num": num_vector, "cat": cat_vector, "bool": bool_vector} class TestCoordinate(DataFixtures): @@ -195,7 +199,7 @@ def test_bad_inference_arg(self, cat_vector): @pytest.mark.parametrize( "data_type,scale_class", - [("cat", Nominal), ("num", Continuous)] + [("cat", Nominal), ("num", Continuous), ("bool", Boolean)] ) def test_default(self, data_type, scale_class, vectors): @@ -213,18 +217,18 @@ def test_default_binary_data(self): scale = Color().default_scale(x) assert isinstance(scale, Continuous) - # TODO default scales for other types - @pytest.mark.parametrize( "values,data_type,scale_class", [ ("viridis", "cat", Nominal), # Based on variable type ("viridis", "num", Continuous), # Based on variable type + ("viridis", "bool", Boolean), # Based on variable type ("muted", "num", Nominal), # Based on qualitative palette (["r", "g", "b"], "num", Nominal), # Based on list palette ({2: "r", 4: "g", 8: "b"}, "num", Nominal), # Based on dict palette (("r", "b"), "num", Continuous), # Based on tuple / variable type (("g", "m"), "cat", Nominal), # Based on tuple / variable type + (("c", "y"), "bool", Boolean), # Based on tuple / variable type (get_colormap("inferno"), "num", Continuous), # Based on callable ] ) @@ -234,12 +238,6 @@ def test_inference(self, values, data_type, scale_class, vectors): assert isinstance(scale, scale_class) assert scale.values == values - def test_inference_binary_data(self): - - x = pd.Series([0, 0, 1, 0, 1], dtype=int) - scale = Color().infer_scale("viridis", x) - assert isinstance(scale, Nominal) - def test_standardization(self): f = Color().standardize @@ -266,26 +264,26 @@ def assert_equal(self, a, b): def unpack(self, x): return x - @pytest.mark.parametrize("data_type", ["cat", "num"]) + @pytest.mark.parametrize("data_type", ["cat", "num", "bool"]) def test_default(self, data_type, vectors): scale = self.prop().default_scale(vectors[data_type]) - assert isinstance(scale, Nominal) + assert isinstance(scale, Boolean if data_type == "bool" else Nominal) - @pytest.mark.parametrize("data_type", ["cat", "num"]) + @pytest.mark.parametrize("data_type", ["cat", "num", "bool"]) def test_inference_list(self, data_type, vectors): scale = self.prop().infer_scale(self.values, vectors[data_type]) - assert isinstance(scale, Nominal) + assert isinstance(scale, Boolean if data_type == "bool" else Nominal) assert scale.values == self.values - @pytest.mark.parametrize("data_type", ["cat", "num"]) + @pytest.mark.parametrize("data_type", ["cat", "num", "bool"]) def test_inference_dict(self, data_type, vectors): x = vectors[data_type] values = dict(zip(categorical_order(x), self.values)) scale = self.prop().infer_scale(values, x) - assert isinstance(scale, Nominal) + assert isinstance(scale, Boolean if data_type == "bool" else Nominal) assert scale.values == values def test_dict_missing(self, cat_vector): @@ -420,14 +418,14 @@ def test_default(self, data_type, vectors): x = vectors[data_type] scale = Fill().default_scale(x) - assert isinstance(scale, Nominal) + assert isinstance(scale, Boolean if data_type == "bool" else Nominal) @pytest.mark.parametrize("data_type", ["cat", "num", "bool"]) def test_inference_list(self, data_type, vectors): x = vectors[data_type] scale = Fill().infer_scale([True, False], x) - assert isinstance(scale, Nominal) + assert isinstance(scale, Boolean if data_type == "bool" else Nominal) assert scale.values == [True, False] @pytest.mark.parametrize("data_type", ["cat", "num", "bool"]) @@ -436,7 +434,7 @@ def test_inference_dict(self, data_type, vectors): x = vectors[data_type] values = dict(zip(x.unique(), [True, False])) scale = Fill().infer_scale(values, x) - assert isinstance(scale, Nominal) + assert isinstance(scale, Boolean if data_type == "bool" else Nominal) assert scale.values == values def test_mapping_categorical_data(self, cat_vector): @@ -486,6 +484,7 @@ def norm(self, x): @pytest.mark.parametrize("data_type,scale_class", [ ("cat", Nominal), ("num", Continuous), + ("bool", Boolean), ]) def test_default(self, data_type, scale_class, vectors): @@ -496,10 +495,13 @@ def test_default(self, data_type, scale_class, vectors): @pytest.mark.parametrize("arg,data_type,scale_class", [ ((1, 3), "cat", Nominal), ((1, 3), "num", Continuous), + ((1, 3), "bool", Boolean), ([1, 2, 3], "cat", Nominal), ([1, 2, 3], "num", Nominal), + ([1, 3], "bool", Boolean), ({"a": 1, "b": 3, "c": 2}, "cat", Nominal), ({2: 1, 4: 3, 8: 2}, "num", Nominal), + ({True: 4, False: 2}, "bool", Boolean), ]) def test_inference(self, arg, data_type, scale_class, vectors): diff --git a/tests/_core/test_rules.py b/tests/_core/test_rules.py index 655840a8d1..8dce38864e 100644 --- a/tests/_core/test_rules.py +++ b/tests/_core/test_rules.py @@ -4,6 +4,7 @@ import pytest +from seaborn.external.version import Version from seaborn._core.rules import ( VarType, variable_type, @@ -35,9 +36,12 @@ def test_variable_type(): assert variable_type(s) == "numeric" s = pd.Series([np.nan, np.nan]) - # s = pd.Series([pd.NA, pd.NA]) assert variable_type(s) == "numeric" + if Version(pd.__version__) >= Version("1.0.0"): + s = pd.Series([pd.NA, pd.NA]) + assert variable_type(s) == "numeric" + s = pd.Series(["1", "2", "3"]) assert variable_type(s) == "categorical" assert variable_type(s.to_numpy()) == "categorical" @@ -46,9 +50,16 @@ def test_variable_type(): s = pd.Series([True, False, False]) assert variable_type(s) == "numeric" assert variable_type(s, boolean_type="categorical") == "categorical" + assert variable_type(s, boolean_type="boolean") == "boolean" + s_cat = s.astype("category") assert variable_type(s_cat, boolean_type="categorical") == "categorical" assert variable_type(s_cat, boolean_type="numeric") == "categorical" + assert variable_type(s_cat, boolean_type="boolean") == "categorical" + + s = pd.Series([1, 0, 0]) + assert variable_type(s, boolean_type="boolean") == "boolean" + assert variable_type(s, boolean_type="boolean", strict_boolean=True) == "numeric" s = pd.Series([pd.Timestamp(1), pd.Timestamp(2)]) assert variable_type(s) == "datetime" diff --git a/tests/_core/test_scales.py b/tests/_core/test_scales.py index ac77098c64..fb314b65d5 100644 --- a/tests/_core/test_scales.py +++ b/tests/_core/test_scales.py @@ -8,9 +8,11 @@ from numpy.testing import assert_array_equal from pandas.testing import assert_series_equal +from seaborn._core.plot import Plot from seaborn._core.scales import ( Nominal, Continuous, + Boolean, Temporal, PseudoAxis, ) @@ -567,6 +569,22 @@ def test_empty_data(self): s = Nominal()._setup(x, Coordinate()) assert_array_equal(s(x), []) + @pytest.mark.skipif( + Version(mpl.__version__) < Version("3.4.0"), + reason="Test failing on older matplotlib for unclear reasons", + ) + def test_finalize(self, x): + + ax = mpl.figure.Figure().subplots() + s = Nominal()._setup(x, Coordinate(), ax.yaxis) + s._finalize(Plot(), ax.yaxis) + + levels = x.unique() + assert ax.get_ylim() == (len(levels) - .5, -.5) + assert_array_equal(ax.get_yticks(), list(range(len(levels)))) + for i, expected in enumerate(levels): + assert ax.yaxis.major.formatter(i) == expected + class TestTemporal: @@ -670,3 +688,138 @@ def test_label_concise(self, t, x): Temporal().label(concise=True)._setup(t, Coordinate(), ax.xaxis) formatter = ax.xaxis.get_major_formatter() assert isinstance(formatter, mpl.dates.ConciseDateFormatter) + + +class TestBoolean: + + @pytest.fixture + def x(self): + return pd.Series([True, False, False, True], name="x", dtype=bool) + + def test_coordinate(self, x): + + s = Boolean()._setup(x, Coordinate()) + assert_array_equal(s(x), x.astype(float)) + + def test_coordinate_axis(self, x): + + ax = mpl.figure.Figure().subplots() + s = Boolean()._setup(x, Coordinate(), ax.xaxis) + assert_array_equal(s(x), x.astype(float)) + f = ax.xaxis.get_major_formatter() + assert f.format_ticks([0, 1]) == ["False", "True"] + + @pytest.mark.parametrize( + "dtype,value", + [ + (object, np.nan), + (object, None), + # TODO add boolean when we don't need the skipif below + ] + ) + def test_coordinate_missing(self, x, dtype, value): + + x = x.astype(dtype) + x[2] = value + s = Boolean()._setup(x, Coordinate()) + assert_array_equal(s(x), x.astype(float)) + + @pytest.mark.skipif( + # TODO merge into test above when removing + Version(pd.__version__) < Version("1.0.0"), + reason="Test requires nullable booleans", + ) + def test_coordinate_with_pd_na(self, x): + + x = x.astype("boolean") + x[2] = pd.NA + s = Boolean()._setup(x, Coordinate()) + assert_array_equal(s(x), x.astype(float)) + + def test_color_defaults(self, x): + + s = Boolean()._setup(x, Color()) + cs = color_palette() + expected = [cs[int(x_i)] for x_i in ~x] + assert_array_equal(s(x), expected) + + def test_color_list_palette(self, x): + + cs = color_palette("crest", 2) + s = Boolean(cs)._setup(x, Color()) + expected = [cs[int(x_i)] for x_i in ~x] + assert_array_equal(s(x), expected) + + def test_color_tuple_palette(self, x): + + cs = tuple(color_palette("crest", 2)) + s = Boolean(cs)._setup(x, Color()) + expected = [cs[int(x_i)] for x_i in ~x] + assert_array_equal(s(x), expected) + + def test_color_dict_palette(self, x): + + cs = color_palette("crest", 2) + pal = {True: cs[0], False: cs[1]} + s = Boolean(pal)._setup(x, Color()) + expected = [pal[x_i] for x_i in x] + assert_array_equal(s(x), expected) + + def test_object_defaults(self, x): + + vs = ["x", "y", "z"] + + class MockProperty(ObjectProperty): + def _default_values(self, n): + return vs[:n] + + s = Boolean()._setup(x, MockProperty()) + expected = [vs[int(x_i)] for x_i in ~x] + assert s(x) == expected + + def test_object_list(self, x): + + vs = ["x", "y"] + s = Boolean(vs)._setup(x, ObjectProperty()) + expected = [vs[int(x_i)] for x_i in ~x] + assert s(x) == expected + + def test_object_dict(self, x): + + vs = {True: "x", False: "y"} + s = Boolean(vs)._setup(x, ObjectProperty()) + expected = [vs[x_i] for x_i in x] + assert s(x) == expected + + def test_fill(self, x): + + s = Boolean()._setup(x, Fill()) + assert_array_equal(s(x), x) + + def test_interval_defaults(self, x): + + vs = (1, 2) + + class MockProperty(IntervalProperty): + _default_range = vs + + s = Boolean()._setup(x, MockProperty()) + expected = [vs[int(x_i)] for x_i in x] + assert_array_equal(s(x), expected) + + def test_interval_tuple(self, x): + + vs = (3, 5) + s = Boolean(vs)._setup(x, IntervalProperty()) + expected = [vs[int(x_i)] for x_i in x] + assert_array_equal(s(x), expected) + + def test_finalize(self, x): + + ax = mpl.figure.Figure().subplots() + s = Boolean()._setup(x, Coordinate(), ax.xaxis) + s._finalize(Plot(), ax.xaxis) + assert ax.get_xlim() == (1.5, -.5) + assert_array_equal(ax.get_xticks(), [0, 1]) + assert ax.xaxis.major.formatter(0) == "False" + assert ax.xaxis.major.formatter(1) == "True" From bf9fcb307533fad2031ab556f5f0e896bbc31e8d Mon Sep 17 00:00:00 2001 From: Michael Waskom Date: Fri, 30 Dec 2022 12:57:46 -0500 Subject: [PATCH 7/9] Explicitly set inline backend when building docs from notebooks --- doc/_docstrings/Makefile | 1 + doc/_tutorial/Makefile | 1 + 2 files changed, 2 insertions(+) diff --git a/doc/_docstrings/Makefile b/doc/_docstrings/Makefile index 537628e236..11657fef0e 100644 --- a/doc/_docstrings/Makefile +++ b/doc/_docstrings/Makefile @@ -1,4 +1,5 @@ rst_files := $(patsubst %.ipynb,../docstrings/%.rst,$(wildcard *.ipynb)) +export MPLBACKEND := module://matplotlib_inline.backend_inline docstrings: ${rst_files} diff --git a/doc/_tutorial/Makefile b/doc/_tutorial/Makefile index c2a2811221..73168b3edc 100644 --- a/doc/_tutorial/Makefile +++ b/doc/_tutorial/Makefile @@ -1,4 +1,5 @@ rst_files := $(patsubst %.ipynb,../tutorial/%.rst,$(wildcard *.ipynb)) +export MPLBACKEND := module://matplotlib_inline.backend_inline tutorial: ${rst_files} From 0f5182ee01d28f2daeee805c7d4deb4a05b30bf0 Mon Sep 17 00:00:00 2001 From: thuiop Date: Fri, 30 Dec 2022 19:15:42 +0100 Subject: [PATCH 8/9] Fix issue when an empty palette is provided for an empty dataset (#3136) * Fix issue when an empty palette is provided for an empty dataset * Added test for empty palette with swarmplot or stripplot --- seaborn/categorical.py | 4 ++-- tests/test_categorical.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/seaborn/categorical.py b/seaborn/categorical.py index cd2bfe4aa6..e22d301b75 100644 --- a/seaborn/categorical.py +++ b/seaborn/categorical.py @@ -236,7 +236,7 @@ def _native_width(self): def _nested_offsets(self, width, dodge): """Return offsets for each hue level for dodged plots.""" offsets = None - if "hue" in self.variables: + if "hue" in self.variables and self._hue_map.levels is not None: n_levels = len(self._hue_map.levels) if dodge: each_width = width / n_levels @@ -268,7 +268,7 @@ def plot_strips( jlim = 0.1 else: jlim = float(jitter) - if "hue" in self.variables and dodge: + if "hue" in self.variables and dodge and self._hue_map.levels is not None: jlim /= len(self._hue_map.levels) jlim *= self._native_width jitterer = partial(np.random.uniform, low=-jlim, high=+jlim) diff --git a/tests/test_categorical.py b/tests/test_categorical.py index 8b84c40801..1e175991da 100644 --- a/tests/test_categorical.py +++ b/tests/test_categorical.py @@ -2137,6 +2137,9 @@ def test_vs_catplot(self, long_df, wide_df, kwargs): assert_plots_equal(ax, g.ax) + def test_empty_palette(self): + self.func(x=[], y=[], hue=[], palette=[]) + class TestStripPlot(SharedScatterTests): From 10c3ed87a42bcaed74b115252eeb4b88ebc1597f Mon Sep 17 00:00:00 2001 From: Michael Waskom Date: Fri, 30 Dec 2022 13:26:55 -0500 Subject: [PATCH 9/9] Finalize v0.12.2 release notes --- doc/whatsnew/v0.12.2.rst | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/doc/whatsnew/v0.12.2.rst b/doc/whatsnew/v0.12.2.rst index 1daf67868b..75bd4e7e61 100644 --- a/doc/whatsnew/v0.12.2.rst +++ b/doc/whatsnew/v0.12.2.rst @@ -1,23 +1,25 @@ -v0.12.2 (Unreleased) --------------------- +v0.12.2 (December 2022) +----------------------- + +This is an incremental release that is a recommended upgrade for all users. It is very likely the final release of the 0.12 series and the last version to support Python 3.7. - |Feature| Added the :class:`objects.KDE` stat (:pr:`3111`). - |Feature| Added the :class:`objects.Boolean` scale (:pr:`3205`). -- |Enhancement| Automatic mark widths are now calculated separately for unshared facet axes (:pr:`3119`). - -- |Enhancement| Improved user feedback for failures during plot compilation by catching exceptions an reraising with a `PlotSpecError` that provides additional context (:pr:`3203`). +- |Enhancement| Improved user feedback for failures during plot compilation by catching exceptions and re-raising with a `PlotSpecError` that provides additional context. (:pr:`3203`). -- |Fix| Fixed a bug where legends for numeric variables with large values with be incorrectly shown (i.e. with a missing offset or exponent; :pr:`3187`). +- |Fix| Improved calculation of automatic mark widths with unshared facet axes (:pr:`3119`). - |Fix| Improved robustness to empty data in several components of the objects interface (:pr:`3202`). -- |Fix| Fixed a regression in v0.12.0 where manually-added labels could have duplicate legend entries (:pr:`3116`). +- |Fix| Fixed a bug where legends for numeric variables with large values would be incorrectly shown (i.e. with a missing offset or exponent; :pr:`3187`). -- |Fix| Normed properties using a :class:`objects.Continuous` scale no longer raise on boolean data (:pr:`3189`). +- |Fix| Fixed a regression in v0.12.0 where manually-added labels could have duplicate legend entries (:pr:`3116`). - |Fix| Fixed a bug in :func:`histplot` with `kde=True` and `log_scale=True` where the curve was not scaled properly (:pr:`3173`). - |Fix| Fixed a bug in :func:`relplot` where inner axis labels would be shown when axis sharing was disabled (:pr:`3180`). + +- |Fix| Fixed a bug in :class:`objects.Continuous` to avoid an exception with boolean data (:pr:`3189`).