diff --git a/doc/_docstrings/objects.Plot.config.ipynb b/doc/_docstrings/objects.Plot.config.ipynb new file mode 100644 index 0000000000..3b0ba2dcef --- /dev/null +++ b/doc/_docstrings/objects.Plot.config.ipynb @@ -0,0 +1,124 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "a38a6fed-51de-4dbc-8d5b-4971d06acf2e", + "metadata": { + "tags": [ + "hide" + ] + }, + "outputs": [], + "source": [ + "import seaborn.objects as so" + ] + }, + { + "cell_type": "raw", + "id": "38081259-9382-4623-8d67-09aa114e0949", + "metadata": {}, + "source": [ + "Theme configuration\n", + "^^^^^^^^^^^^^^^^^^^\n", + "\n", + "The theme is a dictionary of matplotlib `rc parameters `_. You can set individual parameters directly:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34ca0ce9-5284-47b6-8281-180709dbec89", + "metadata": {}, + "outputs": [], + "source": [ + "so.Plot.config.theme[\"axes.facecolor\"] = \"white\"" + ] + }, + { + "cell_type": "raw", + "id": "b3f93646-8370-4c16-ace4-7bb811688758", + "metadata": {}, + "source": [ + "To change the overall style of the plot, update the theme with a dictionary of parameters, perhaps from one of seaborn's theming functions:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e5eb7d3-cc7a-4231-b887-db37045f3db4", + "metadata": {}, + "outputs": [], + "source": [ + "from seaborn import axes_style\n", + "so.Plot.config.theme.update(axes_style(\"whitegrid\"))" + ] + }, + { + "cell_type": "raw", + "id": "f7c7bd9c-722d-45db-902a-c2dcdef571ee", + "metadata": {}, + "source": [ + "To sync :class:`Plot` with matplotlib's global state, pass the `rcParams` dictionary:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fd1cd96e-1a2c-474a-809f-20b8c4794578", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib as mpl\n", + "so.Plot.config.theme.update(mpl.rcParams)" + ] + }, + { + "cell_type": "raw", + "id": "7e305ec1-4a83-411f-91df-aee2ec4d1806", + "metadata": {}, + "source": [ + "The theme can also be reset back to seaborn defaults:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e3146b1d-1b5e-464f-a631-e6d6caf161b3", + "metadata": {}, + "outputs": [], + "source": [ + "so.Plot.config.theme.reset()" + ] + }, + { + "cell_type": "raw", + "id": "eae5da42-cf7f-41c9-b13d-8fa25e5cf0be", + "metadata": {}, + "source": [ + "Changes made through this interface will apply to all subsequent :class:`Plot` instances. Use the :meth:`Plot.theme` method to modify the theme on a plot-by-plot basis." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "py310", + "language": "python", + "name": "py310" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/_docstrings/objects.Plot.theme.ipynb b/doc/_docstrings/objects.Plot.theme.ipynb index 46f22f51df..df98bc456b 100644 --- a/doc/_docstrings/objects.Plot.theme.ipynb +++ b/doc/_docstrings/objects.Plot.theme.ipynb @@ -32,7 +32,7 @@ "outputs": [], "source": [ "p = (\n", - " so.Plot(anscombe, \"x\", \"y\")\n", + " so.Plot(anscombe, \"x\", \"y\", color=\"dataset\")\n", " .facet(\"dataset\", wrap=2)\n", " .add(so.Line(), so.PolyFit(order=1))\n", " .add(so.Dot())\n", @@ -55,7 +55,7 @@ "metadata": {}, "outputs": [], "source": [ - "p.theme({\"axes.facecolor\": \"w\", \"axes.edgecolor\": \"C0\"})" + "p.theme({\"axes.facecolor\": \"w\", \"axes.edgecolor\": \"slategray\"})" ] }, { @@ -77,8 +77,8 @@ ] }, { - "cell_type": "markdown", - "id": "2c8fa27e-d1ea-4376-a717-c3059ba1d272", + "cell_type": "raw", + "id": "0186e852-9c47-4da1-999a-f61f41687dfb", "metadata": {}, "source": [ "Apply seaborn styles by passing in the output of the style functions:" @@ -92,7 +92,7 @@ "outputs": [], "source": [ "from seaborn import axes_style\n", - "p.theme({**axes_style(\"ticks\")})" + "p.theme(axes_style(\"ticks\"))" ] }, { @@ -111,7 +111,15 @@ "outputs": [], "source": [ "from matplotlib import style\n", - "p.theme({**style.library[\"fivethirtyeight\"]})" + "p.theme(style.library[\"fivethirtyeight\"])" + ] + }, + { + "cell_type": "raw", + "id": "e1870ad0-48a0-4fd1-a557-d337979bc845", + "metadata": {}, + "source": [ + "Multiple parameter dictionaries should be passed to the same function call. On Python 3.9+, you can use dictionary union syntax for this:" ] }, { @@ -120,7 +128,37 @@ "id": "dec4db5b-1b2b-4b9d-97e1-9cf0f20d6b83", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "from seaborn import plotting_context\n", + "p.theme(axes_style(\"whitegrid\") | plotting_context(\"talk\"))" + ] + }, + { + "cell_type": "raw", + "id": "7cc09720-887d-463e-a162-1e3ef8a46ad9", + "metadata": {}, + "source": [ + "The default theme for all :class:`Plot` instances can be changed using the :attr:`Plot.config` attribute:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4e535ddf-d394-4ce1-8d09-4dc95ca314b4", + "metadata": {}, + "outputs": [], + "source": [ + "so.Plot.config.theme.update(axes_style(\"white\"))\n", + "p" + ] + }, + { + "cell_type": "raw", + "id": "2f19f645-3f8d-4044-82e9-4a87165a0078", + "metadata": {}, + "source": [ + "See :ref:`Plot Configuration ` for more details." + ] } ], "metadata": { diff --git a/doc/_templates/autosummary/plot.rst b/doc/_templates/autosummary/plot.rst index 65b97b856f..aae1c66570 100644 --- a/doc/_templates/autosummary/plot.rst +++ b/doc/_templates/autosummary/plot.rst @@ -4,54 +4,66 @@ .. autoclass:: {{ objname }} - {% block methods %} +{% block methods %} - .. rubric:: Specification methods +Methods +~~~~~~~ - .. autosummary:: - :toctree: ./ - :nosignatures: +.. rubric:: Specification methods - ~Plot.add - ~Plot.scale +.. autosummary:: + :toctree: ./ + :nosignatures: - .. rubric:: Subplot methods + ~Plot.add + ~Plot.scale - .. autosummary:: - :toctree: ./ - :nosignatures: +.. rubric:: Subplot methods - ~Plot.facet - ~Plot.pair +.. autosummary:: + :toctree: ./ + :nosignatures: - .. rubric:: Customization methods + ~Plot.facet + ~Plot.pair - .. autosummary:: - :toctree: ./ - :nosignatures: +.. rubric:: Customization methods - ~Plot.layout - ~Plot.label - ~Plot.limit - ~Plot.share - ~Plot.theme +.. autosummary:: + :toctree: ./ + :nosignatures: - .. rubric:: Integration methods + ~Plot.layout + ~Plot.label + ~Plot.limit + ~Plot.share + ~Plot.theme - .. autosummary:: - :toctree: ./ - :nosignatures: +.. rubric:: Integration methods - ~Plot.on +.. autosummary:: + :toctree: ./ + :nosignatures: - .. rubric:: Output methods + ~Plot.on - .. autosummary:: - :toctree: ./ - :nosignatures: +.. rubric:: Output methods - ~Plot.plot - ~Plot.save - ~Plot.show +.. autosummary:: + :toctree: ./ + :nosignatures: - {% endblock %} + ~Plot.plot + ~Plot.save + ~Plot.show + +{% endblock %} + +.. _plot_config: + +Configuration +~~~~~~~~~~~~~ + +The :class:`Plot` object's default behavior can be configured through its :attr:`Plot.config` attribute. Notice that this is a property of the class, not a method on an instance. + +.. include:: ../docstrings/objects.Plot.config.rst diff --git a/doc/_tutorial/objects_interface.ipynb b/doc/_tutorial/objects_interface.ipynb index 306875272f..8839baf081 100644 --- a/doc/_tutorial/objects_interface.ipynb +++ b/doc/_tutorial/objects_interface.ipynb @@ -1043,16 +1043,27 @@ "outputs": [], "source": [ "from seaborn import axes_style\n", - "so.Plot().theme({**axes_style(\"whitegrid\"), \"grid.linestyle\": \":\"})" + "theme_dict = {**axes_style(\"whitegrid\"), \"grid.linestyle\": \":\"}\n", + "so.Plot().theme(theme_dict)" + ] + }, + { + "cell_type": "raw", + "id": "475d5157-5e88-473e-991f-528219ed3744", + "metadata": {}, + "source": [ + "To change the theme for all :class:`Plot` instances, update the settings in :attr:`Plot.config:" ] }, { "cell_type": "code", "execution_count": null, - "id": "8ac5e809-e4a0-4c08-b9c0-fa78bd93eb82", + "id": "41ac347c-766f-495c-8a7f-43fee8cad29a", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "so.Plot.config.theme.update(theme_dict)" + ] } ], "metadata": { diff --git a/seaborn/_core/plot.py b/seaborn/_core/plot.py index 0da6ba6f90..79d0ef5b98 100644 --- a/seaborn/_core/plot.py +++ b/seaborn/_core/plot.py @@ -57,7 +57,7 @@ default = Default() -# ---- Definitions for internal specs --------------------------------- # +# ---- Definitions for internal specs ---------------------------------------------- # class Layer(TypedDict, total=False): @@ -87,7 +87,7 @@ class PairSpec(TypedDict, total=False): wrap: int | None -# --- Local helpers ---------------------------------------------------------------- +# --- Local helpers ---------------------------------------------------------------- # @contextmanager @@ -143,7 +143,85 @@ def build_plot_signature(cls): return cls -# ---- The main interface for declarative plotting -------------------- # +# ---- Plot configuration ---------------------------------------------------------- # + + +class ThemeConfig(mpl.RcParams): + """ + Configuration object for the Plot.theme, using matplotlib rc parameters. + """ + THEME_GROUPS = [ + "axes", "figure", "font", "grid", "hatch", "legend", "lines", + "mathtext", "markers", "patch", "savefig", "scatter", + "xaxis", "xtick", "yaxis", "ytick", + ] + + def __init__(self): + super().__init__() + self.reset() + + @property + def _default(self) -> dict[str, Any]: + + return { + **self._filter_params(mpl.rcParamsDefault), + **axes_style("darkgrid"), + **plotting_context("notebook"), + "axes.prop_cycle": cycler("color", color_palette("deep")), + } + + def reset(self) -> None: + """Update the theme dictionary with seaborn's default values.""" + self.update(self._default) + + def update(self, other: dict[str, Any] | None = None, /, **kwds): + """Update the theme with a dictionary or keyword arguments of rc parameters.""" + if other is not None: + theme = self._filter_params(other) + else: + theme = {} + theme.update(kwds) + super().update(theme) + + def _filter_params(self, params: dict[str, Any]) -> dict[str, Any]: + """Restruct to thematic rc params.""" + return { + k: v for k, v in params.items() + if any(k.startswith(p) for p in self.THEME_GROUPS) + } + + def _html_table(self, params: dict[str, Any]) -> list[str]: + + lines = [""] + for k, v in params.items(): + row = f"" + lines.append(row) + lines.append("
{k}:{v!r}
") + return lines + + def _repr_html_(self) -> str: + + repr = [ + "
", + "
", + *self._html_table(self), + "
", + "
", + ] + return "\n".join(repr) + + +class PlotConfig: + """Configuration for default behavior / appearance of class:`Plot` instances.""" + _theme = ThemeConfig() + + @property + def theme(self) -> dict[str, Any]: + """Dictionary of base theme parameters for :class:`Plot`.""" + return self._theme + + +# ---- The main interface for declarative plotting --------------------------------- # @build_plot_signature @@ -181,6 +259,8 @@ class Plot: the plot without rendering it to access the lower-level representation. """ + config = PlotConfig() + _data: PlotData _layers: list[Layer] @@ -308,21 +388,7 @@ def _clone(self) -> Plot: def _theme_with_defaults(self) -> dict[str, Any]: - style_groups = [ - "axes", "figure", "font", "grid", "hatch", "legend", "lines", - "mathtext", "markers", "patch", "savefig", "scatter", - "xaxis", "xtick", "yaxis", "ytick", - ] - base = { - k: mpl.rcParamsDefault[k] for k in mpl.rcParams - if any(k.startswith(p) for p in style_groups) - } - theme = { - **base, - **axes_style("darkgrid"), - **plotting_context("notebook"), - "axes.prop_cycle": cycler("color", color_palette("deep")), - } + theme = self.config.theme.copy() theme.update(self._theme) return theme @@ -744,7 +810,7 @@ def layout( def theme(self, *args: dict[str, Any]) -> Plot: """ - Control the default appearance of elements in the plot. + Control the appearance of elements in the plot. .. note:: @@ -770,7 +836,7 @@ def theme(self, *args: dict[str, Any]) -> Plot: err = f"theme() takes 1 positional argument, but {nargs} were given" raise TypeError(err) - rc = args[0] + rc = mpl.RcParams(args[0]) new._theme.update(rc) return new @@ -937,7 +1003,7 @@ def _repr_png_(self) -> tuple[bytes, dict[str, float]]: dpi = 96 buffer = io.BytesIO() - with theme_context(self._theme): + with theme_context(self._theme): # TODO _theme_with_defaults? self._figure.savefig(buffer, dpi=dpi * 2, format="png", bbox_inches="tight") data = buffer.getvalue() @@ -1669,4 +1735,5 @@ def _finalize_figure(self, p: Plot) -> None: # Don't modify the layout engine if the user supplied their own # matplotlib figure and didn't specify an engine through Plot # TODO switch default to "constrained"? + # TODO either way, make configurable set_layout_engine(self._figure, "tight") diff --git a/tests/_core/test_plot.py b/tests/_core/test_plot.py index d7a950b6ae..af81685e58 100644 --- a/tests/_core/test_plot.py +++ b/tests/_core/test_plot.py @@ -922,6 +922,16 @@ def test_theme_error(self): with pytest.raises(TypeError, match=r"theme\(\) takes 1 positional"): p.theme("arg1", "arg2") + def test_theme_validation(self): + + p = Plot() + # You'd think matplotlib would raise a TypeError here, but it doesn't + with pytest.raises(ValueError, match="Key axes.linewidth:"): + p.theme({"axes.linewidth": "thick"}) + + with pytest.raises(KeyError, match="not.a.key is not a valid rc"): + p.theme({"not.a.key": True}) + def test_stat(self, long_df): orig_df = long_df.copy(deep=True) @@ -2126,3 +2136,60 @@ class TestDefaultObject: def test_default_repr(self): assert repr(Default()) == "" + + +class TestThemeConfig: + + @pytest.fixture(autouse=True) + def reset_config(self): + yield + Plot.config.theme.reset() + + def test_default(self): + + p = Plot().plot() + ax = p._figure.axes[0] + expected = Plot.config.theme["axes.facecolor"] + assert mpl.colors.same_color(ax.get_facecolor(), expected) + + def test_setitem(self): + + color = "#CCC" + Plot.config.theme["axes.facecolor"] = color + p = Plot().plot() + ax = p._figure.axes[0] + assert mpl.colors.same_color(ax.get_facecolor(), color) + + def test_update(self): + + color = "#DDD" + Plot.config.theme.update({"axes.facecolor": color}) + p = Plot().plot() + ax = p._figure.axes[0] + assert mpl.colors.same_color(ax.get_facecolor(), color) + + def test_reset(self): + + orig = Plot.config.theme["axes.facecolor"] + Plot.config.theme.update({"axes.facecolor": "#EEE"}) + Plot.config.theme.reset() + p = Plot().plot() + ax = p._figure.axes[0] + assert mpl.colors.same_color(ax.get_facecolor(), orig) + + def test_copy(self): + + key, val = "axes.facecolor", ".95" + orig = Plot.config.theme[key] + theme = Plot.config.theme.copy() + theme.update({key: val}) + assert Plot.config.theme[key] == orig + + def test_html_repr(self): + + res = Plot.config.theme._repr_html_() + for tag in ["div", "table", "tr", "td"]: + assert res.count(f"<{tag}") == res.count(f"{key}:" in res