Source code for plotnine.themes.theme

from __future__ import annotations

import typing
from copy import copy, deepcopy
from typing import overload

from ..exceptions import PlotnineError
from ..options import get_option, set_option
from .themeable import Themeables, themeable

if typing.TYPE_CHECKING:
    from typing import Any, Type

    from plotnine.typing import Axes, Figure, Ggplot

# All complete themes are initiated with these rcparams. They
# can be overridden.
default_rcparams = {
    "axes.axisbelow": "True",
    "font.sans-serif": [
        "Helvetica",
        "DejaVu Sans",  # MPL ships with this one
        "Avant Garde",
        "Computer Modern Sans serif",
        "Arial",
    ],
    "font.serif": [
        "Times",
        "Palatino",
        "New Century Schoolbook",
        "Bookman",
        "Computer Modern Roman",
        "Times New Roman",
    ],
    "lines.antialiased": "True",
    "patch.antialiased": "True",
    "timezone": "UTC",
}


[docs]class theme: """ Base class for themes In general, only complete themes should subclass this class. Parameters ---------- complete : bool Themes that are complete will override any existing themes. themes that are not complete (ie. partial) will add to or override specific elements of the current theme. e.g:: theme_gray() + theme_xkcd() will be completely determined by :class:`theme_xkcd`, but:: theme_gray() + theme(axis_text_x=element_text(angle=45)) will only modify the x-axis text. kwargs: dict kwargs are :ref:`themeables <themeables>`. The themeables are elements that are subclasses of `themeable`. Many themeables are defined using theme elements i.e - :class:`element_line` - :class:`element_rect` - :class:`element_text` These simply bind together all the aspects of a themeable that can be themed. See :class:`~plotnine.themes.themeable.themeable`. Notes ----- When subclassing, make sure to call :python:`theme.__init__`. After which you can customise :python:`self._rcParams` within the ``__init__`` method of the new theme. The ``rcParams`` should not be modified after that. """ # This is set when the figure is created, # it is useful at legend drawing time and # when applying the theme. figure: Figure axs: list[Axes] complete: bool # Dictionary to collect matplotlib objects that will # be targeted for theming by the themeables # It is initialised in the plot context and removed at # the end of it. _targets: dict[str, Any] def __init__( self, complete=False, # Generate themeables keyword parameters with # # from plotnine.themes.themeable import themeable # for name in themeable.registry(): # print(f'{name}=None,') axis_title_x=None, axis_title_y=None, axis_title=None, legend_title=None, legend_text_legend=None, legend_text_colorbar=None, legend_text=None, plot_title=None, plot_subtitle=None, plot_caption=None, strip_text_x=None, strip_text_y=None, strip_text=None, title=None, axis_text_x=None, axis_text_y=None, axis_text=None, text=None, axis_line_x=None, axis_line_y=None, axis_line=None, axis_ticks_minor_x=None, axis_ticks_minor_y=None, axis_ticks_major_x=None, axis_ticks_major_y=None, axis_ticks_major=None, axis_ticks_minor=None, axis_ticks_x=None, axis_ticks_y=None, axis_ticks=None, panel_grid_major_x=None, panel_grid_major_y=None, panel_grid_minor_x=None, panel_grid_minor_y=None, panel_grid_major=None, panel_grid_minor=None, panel_grid=None, line=None, legend_key=None, legend_background=None, legend_box_background=None, panel_background=None, panel_border=None, plot_background=None, strip_background_x=None, strip_background_y=None, strip_background=None, rect=None, axis_ticks_length_major_x=None, axis_ticks_length_major_y=None, axis_ticks_length_major=None, axis_ticks_length_minor_x=None, axis_ticks_length_minor_y=None, axis_ticks_length_minor=None, axis_ticks_length=None, axis_ticks_pad_major_x=None, axis_ticks_pad_major_y=None, axis_ticks_pad_major=None, axis_ticks_pad_minor_x=None, axis_ticks_pad_minor_y=None, axis_ticks_pad_minor=None, axis_ticks_pad=None, axis_ticks_direction_x=None, axis_ticks_direction_y=None, axis_ticks_direction=None, panel_spacing_x=None, panel_spacing_y=None, panel_spacing=None, plot_margin_left=None, plot_margin_right=None, plot_margin_top=None, plot_margin_bottom=None, plot_margin=None, panel_ontop=None, aspect_ratio=None, dpi=None, figure_size=None, legend_box=None, legend_box_margin=None, legend_box_just=None, legend_direction=None, legend_key_width=None, legend_key_height=None, legend_key_size=None, legend_margin=None, legend_box_spacing=None, legend_spacing=None, legend_position=None, legend_title_align=None, legend_entry_spacing_x=None, legend_entry_spacing_y=None, legend_entry_spacing=None, strip_align_x=None, strip_align_y=None, strip_align=None, subplots_adjust=None, **kwargs, ): self.themeables = Themeables() self.complete = complete if complete: self._rcParams = deepcopy(default_rcparams) else: self._rcParams = {} # Themeables official_themeables = themeable.registry() locals_args = dict(locals()) it = ( (name, element) for name, element in locals_args.items() if element is not None and name in official_themeables ) new = themeable.from_class_name for name, element in it: self.themeables[name] = new(name, element) # Unofficial themeables (for extensions) for name, element in kwargs.items(): self.themeables[name] = new(name, element) def __eq__(self, other: Any) -> bool: """ Test if themes are equal Mostly for testing purposes """ # criteria for equality are # - Equal themeables # - Equal rcParams c1 = self.themeables == other.themeables c2 = self.rcParams == other.rcParams return c1 and c2 def apply(self): """ Apply this theme, then apply additional modifications in order. This method will be called once after plot has completed. Subclasses that override this method should make sure that the base class method is called. """ for th in self.themeables.values(): th.apply(self) def setup(self): """ Setup theme & figure for before drawing 1. The figure is modified with to the theme settings that it are required before drawing. 2. Give contained objects of the theme/themeables a reference to the theme. This method will be called once with a figure object before any plotting has completed. Subclasses that override this method should make sure that the base class method is called. """ from .elements import element_text for th in self.themeables.values(): th.setup_figure(self.figure) if isinstance(th.theme_element, element_text): th.theme_element.setup(self) @property def rcParams(self): """ Return rcParams dict for this theme. Notes ----- Subclasses should not need to override this method method as long as self._rcParams is constructed properly. rcParams are used during plotting. Sometimes the same theme can be achieved by setting rcParams before plotting or a apply after plotting. The choice of how to implement it is is a matter of convenience in that case. There are certain things can only be themed after plotting. There may not be an rcParam to control the theme or the act of plotting may cause an entity to come into existence before it can be themed. """ try: rcParams = deepcopy(self._rcParams) except NotImplementedError: # deepcopy raises an error for objects that are drived from or # composed of matplotlib.transform.TransformNode. # Not desirable, but probably requires upstream fix. # In particular, XKCD uses matplotlib.patheffects.withStrok rcParams = copy(self._rcParams) for th in self.themeables.values(): rcParams.update(th.rcParams) return rcParams def add_theme(self, other: theme) -> theme: """ Add themes together Subclasses should not override this method. This will be called when adding two instances of class 'theme' together. A complete theme will annihilate any previous themes. Partial themes can be added together and can be added to a complete theme. """ if other.complete: return other self.themeables.update(deepcopy(other.themeables)) return self def __add__(self, other: theme) -> theme: """ Add other theme to this theme """ if not isinstance(other, theme): msg = f"Adding theme failed. {other} is not a theme" raise PlotnineError(msg) self = deepcopy(self) return self.add_theme(other) @overload def __radd__(self, other: theme) -> theme: ... @overload def __radd__(self, other: Ggplot) -> Ggplot: ... def __radd__(self, other: theme | Ggplot) -> theme | Ggplot: """ Add theme to ggplot object or to another theme This will be called in one of two ways:: ggplot() + theme() theme1() + theme2() In both cases, `self` is the :class:`theme` on the right hand side. Subclasses should not override this method. """ # ggplot() + theme, get theme # if hasattr(other, 'theme'): if not isinstance(other, theme): if self.complete: other.theme = self else: # If no theme has been added yet, # we modify the default theme other.theme = other.theme or theme_get() other.theme = other.theme.add_theme(self) return other # theme1 + theme2 else: if self.complete: # e.g. other + theme_gray() return self else: # e.g. other + theme(...) return other.add_theme(self) def __iadd__(self, other: theme) -> theme: """ Add theme to theme """ return self.add_theme(other) def __deepcopy__(self, memo: dict) -> theme: """ Deep copy without copying the figure """ cls = self.__class__ result = cls.__new__(cls) memo[id(self)] = result old = self.__dict__ new = result.__dict__ shallow = {"figure", "_targets"} for key, item in old.items(): if key in shallow: new[key] = old[key] memo[id(new[key])] = new[key] else: new[key] = deepcopy(old[key], memo) return result
[docs]def theme_get() -> theme: """ Return the default theme The default theme is the one set (using :func:`theme_set`) by the user. If none has been set, then :class:`theme_gray` is the default. """ from .theme_gray import theme_gray _theme = get_option("current_theme") if isinstance(_theme, type): _theme = _theme() return _theme or theme_gray()
[docs]def theme_set(new: theme | Type[theme]) -> theme: """ Change the current(default) theme Parameters ---------- new : theme New default theme Returns ------- out : theme Previous theme """ if not isinstance(new, theme) and not issubclass(new, theme): raise PlotnineError("Expecting object to be a theme") out: theme = get_option("current_theme") set_option("current_theme", new) return out
[docs]def theme_update(**kwargs: themeable): """ Modify elements of the current theme Parameters ---------- kwargs : dict Theme elements """ assert "complete" not in kwargs theme_set(theme_get() + theme(**kwargs)) # pyright: ignore