diff --git a/plotnine/guides/guide_legend.py b/plotnine/guides/guide_legend.py index 02311717c..15aeaacd4 100644 --- a/plotnine/guides/guide_legend.py +++ b/plotnine/guides/guide_legend.py @@ -184,10 +184,11 @@ def create_geoms(self): # Modify aesthetics # When doing after_scale evaluations, we only consider those - # for the aesthetics that are valid for this layer/geom. + # for aesthetics that can be in the legend key data. + legend_aes = l.geom.DEFAULT_AES.keys() | matched_set aes_modifiers = { ae: l.mapping._scaled[ae] - for ae in l.geom.aesthetics() & l.mapping._scaled.keys() + for ae in legend_aes & l.mapping._scaled.keys() } try: diff --git a/plotnine/scales/_runtime_typing.py b/plotnine/scales/_runtime_typing.py index 273c7f7ec..9d82a647e 100644 --- a/plotnine/scales/_runtime_typing.py +++ b/plotnine/scales/_runtime_typing.py @@ -5,7 +5,7 @@ the documentation. """ -from typing import Callable, Sequence, Type, TypeAlias, TypeVar +from typing import Callable, Literal, Sequence, Type, TypeAlias, TypeVar from mizani.transforms import trans @@ -51,6 +51,10 @@ TransUser: TypeAlias = trans | str | Type[trans] | None +OptionalLegend: TypeAlias = Literal["legend"] | None +OptionalColorbar: TypeAlias = Literal["colorbar"] | None +OptionalGuide: TypeAlias = Literal["legend", "colorbar"] | None + RangeT = TypeVar("RangeT", bound=Range) BreaksUserT = TypeVar("BreaksUserT") LimitsUserT = TypeVar("LimitsUserT") diff --git a/plotnine/scales/scale_alpha.py b/plotnine/scales/scale_alpha.py index dbdec00fc..d64e7ccb6 100644 --- a/plotnine/scales/scale_alpha.py +++ b/plotnine/scales/scale_alpha.py @@ -1,9 +1,10 @@ from dataclasses import KW_ONLY, InitVar, dataclass -from typing import Literal from warnings import warn import numpy as np +from plotnine.scales._runtime_typing import OptionalLegend + from .._utils.registry import alias from ..exceptions import PlotnineWarning from .scale_continuous import scale_continuous @@ -12,7 +13,7 @@ @dataclass -class scale_alpha(scale_continuous[Literal["legend"]]): +class scale_alpha(scale_continuous[OptionalLegend]): """ Continuous Alpha Scale """ @@ -25,7 +26,7 @@ class scale_alpha(scale_continuous[Literal["legend"]]): """ _: KW_ONLY - guide: Literal["legend"] = "legend" + guide: OptionalLegend = "legend" def __post_init__(self, range): from mizani.palettes import rescale_pal @@ -40,11 +41,14 @@ class scale_alpha_continuous(scale_alpha): @dataclass -class scale_alpha_ordinal(scale_discrete): +class scale_alpha_ordinal(scale_discrete[OptionalLegend]): """ Ordinal Alpha Scale """ + _: KW_ONLY + guide: OptionalLegend = "legend" + _aesthetics = ["alpha"] range: InitVar[tuple[float, float]] = (0.1, 1) """ @@ -89,7 +93,7 @@ class scale_alpha_datetime(scale_datetime): """ _: KW_ONLY - guide: Literal["legend"] = "legend" + guide: OptionalLegend = "legend" def __post_init__( self, diff --git a/plotnine/scales/scale_color.py b/plotnine/scales/scale_color.py index fb048b5a2..872355533 100644 --- a/plotnine/scales/scale_color.py +++ b/plotnine/scales/scale_color.py @@ -4,6 +4,8 @@ from typing import Literal, Sequence from warnings import warn +from plotnine.scales._runtime_typing import OptionalGuide, OptionalLegend + from .._utils.registry import alias from ..exceptions import PlotnineWarning from .scale_continuous import scale_continuous @@ -12,13 +14,14 @@ @dataclass -class _scale_color_discrete(scale_discrete): +class _scale_color_discrete(scale_discrete[OptionalLegend]): """ Base class for all discrete color scales """ _aesthetics = ["color"] _: KW_ONLY + guide: OptionalLegend = "legend" na_value: str = "#7F7F7F" """ Color of missing values. @@ -27,7 +30,7 @@ class _scale_color_discrete(scale_discrete): @dataclass class _scale_color_continuous( - scale_continuous[Literal["legend", "colorbar"] | None], + scale_continuous[OptionalGuide], ): """ Base class for all continuous color scales @@ -35,7 +38,7 @@ class _scale_color_continuous( _aesthetics = ["color"] _: KW_ONLY - guide: Literal["legend", "colorbar"] | None = "colorbar" + guide: OptionalGuide = "colorbar" na_value: str = "#7F7F7F" """ Color of missing values. @@ -563,7 +566,7 @@ class scale_color_datetime(scale_datetime, scale_color_cmap): # pyright: ignore """ _: KW_ONLY - guide: Literal["legend", "colorbar"] | None = "colorbar" + guide: OptionalGuide = "colorbar" def __post_init__( self, diff --git a/plotnine/scales/scale_discrete.py b/plotnine/scales/scale_discrete.py index 3b391c4bc..557ec1373 100644 --- a/plotnine/scales/scale_discrete.py +++ b/plotnine/scales/scale_discrete.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Literal, Sequence +from typing import TYPE_CHECKING, Any, Sequence import numpy as np import pandas as pd @@ -11,7 +11,7 @@ from .._utils import match from ..iapi import range_view, scale_view from ._expand import expand_range -from ._runtime_typing import DiscreteBreaksUser, DiscreteLimitsUser +from ._runtime_typing import DiscreteBreaksUser, DiscreteLimitsUser, GuideTypeT from .range import RangeDiscrete from .scale import scale @@ -29,7 +29,7 @@ class scale_discrete( RangeDiscrete, DiscreteBreaksUser, DiscreteLimitsUser, - Literal["legend"] | None, + GuideTypeT, ] ): """ @@ -69,8 +69,6 @@ class scale_discrete( `nan` is always placed on the right. """ - guide: Literal["legend"] | None = "legend" - def __post_init__(self): super().__post_init__() self._range = RangeDiscrete() diff --git a/plotnine/scales/scale_identity.py b/plotnine/scales/scale_identity.py index b74111814..44abe3a99 100644 --- a/plotnine/scales/scale_identity.py +++ b/plotnine/scales/scale_identity.py @@ -1,7 +1,9 @@ from __future__ import annotations from dataclasses import KW_ONLY, dataclass -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING + +from plotnine.scales._runtime_typing import OptionalLegend from .._utils.registry import alias from .scale_continuous import scale_continuous @@ -37,14 +39,14 @@ def train(self, x, drop=False): @dataclass -class scale_color_identity(MapTrainMixin, scale_discrete): +class scale_color_identity(MapTrainMixin, scale_discrete[OptionalLegend]): """ No color scaling """ _aesthetics = ["color"] _: KW_ONLY - guide: Literal["legend"] | None = None + guide: OptionalLegend = "legend" @dataclass @@ -54,67 +56,61 @@ class scale_fill_identity(scale_color_identity): """ _aesthetics = ["fill"] - _: KW_ONLY - guide: Literal["legend"] | None = None @dataclass -class scale_shape_identity(MapTrainMixin, scale_discrete): +class scale_shape_identity(MapTrainMixin, scale_discrete[OptionalLegend]): """ No shape scaling """ _aesthetics = ["shape"] _: KW_ONLY - guide: Literal["legend"] | None = None + guide: OptionalLegend = "legend" @dataclass -class scale_linetype_identity(MapTrainMixin, scale_discrete): +class scale_linetype_identity(MapTrainMixin, scale_discrete[OptionalLegend]): """ No linetype scaling """ _aesthetics = ["linetype"] _: KW_ONLY - guide: Literal["legend"] | None = None + guide: OptionalLegend = "legend" @dataclass -class scale_alpha_identity( - MapTrainMixin, scale_continuous[Literal["legend"] | None] -): +class scale_alpha_identity(MapTrainMixin, scale_continuous[OptionalLegend]): """ No alpha scaling """ _aesthetics = ["alpha"] _: KW_ONLY - guide: Literal["legend"] | None = None + guide: OptionalLegend = "legend" @dataclass -class scale_size_identity( - MapTrainMixin, scale_continuous[Literal["legend"] | None] -): +class scale_size_identity(MapTrainMixin, scale_continuous[OptionalLegend]): """ No size scaling """ _aesthetics = ["size"] _: KW_ONLY - guide: Literal["legend"] | None = None + guide: OptionalLegend = None @dataclass -class scale_stroke_identity(MapTrainMixin, scale_discrete): +class scale_stroke_identity(MapTrainMixin, scale_discrete[OptionalLegend]): """ No stroke scaling """ _aesthetics = ["stroke"] _: KW_ONLY - guide: Literal["legend"] | None = None + guide: OptionalLegend = None # American to British spelling diff --git a/plotnine/scales/scale_linetype.py b/plotnine/scales/scale_linetype.py index 8a88dd597..eb5f9d680 100644 --- a/plotnine/scales/scale_linetype.py +++ b/plotnine/scales/scale_linetype.py @@ -1,6 +1,8 @@ -from dataclasses import dataclass +from dataclasses import KW_ONLY, dataclass from warnings import warn +from plotnine.scales._runtime_typing import OptionalLegend + from .._utils.registry import alias from ..exceptions import PlotnineError, PlotnineWarning from .scale_continuous import scale_continuous @@ -10,7 +12,7 @@ @dataclass -class scale_linetype(scale_discrete): +class scale_linetype(scale_discrete[OptionalLegend]): """ Scale for line patterns @@ -24,6 +26,9 @@ class scale_linetype(scale_discrete): _aesthetics = ["linetype"] + _: KW_ONLY + guide: OptionalLegend = "legend" + def __post_init__(self): from mizani.palettes import manual_pal diff --git a/plotnine/scales/scale_manual.py b/plotnine/scales/scale_manual.py index cb0ffaf50..9deaf54bc 100644 --- a/plotnine/scales/scale_manual.py +++ b/plotnine/scales/scale_manual.py @@ -5,13 +5,15 @@ from typing import Any, Sequence from warnings import warn +from plotnine.scales._runtime_typing import OptionalLegend + from .._utils.registry import alias from ..exceptions import PlotnineWarning from .scale_discrete import scale_discrete @dataclass -class _scale_manual(scale_discrete): +class _scale_manual(scale_discrete[OptionalLegend]): """ Abstract class for manual scales """ @@ -20,6 +22,8 @@ class _scale_manual(scale_discrete): """ Exact values the scale should map to. """ + _: KW_ONLY + guide: OptionalLegend = "legend" def __post_init__(self, values): from collections.abc import Iterable, Sized diff --git a/plotnine/scales/scale_shape.py b/plotnine/scales/scale_shape.py index 29248fb17..ff2a77de7 100644 --- a/plotnine/scales/scale_shape.py +++ b/plotnine/scales/scale_shape.py @@ -1,6 +1,8 @@ -from dataclasses import InitVar, dataclass +from dataclasses import KW_ONLY, InitVar, dataclass from warnings import warn +from plotnine.scales._runtime_typing import OptionalLegend + from .._utils.registry import alias from ..exceptions import PlotnineError, PlotnineWarning from .scale_continuous import scale_continuous @@ -50,7 +52,7 @@ @dataclass -class scale_shape(scale_discrete): +class scale_shape(scale_discrete[OptionalLegend]): """ Scale for shapes """ @@ -61,6 +63,8 @@ class scale_shape(scale_discrete): If `True`, then all shapes will have no interiors that can be a filled. """ + _: KW_ONLY + guide: OptionalLegend = "legend" def __post_init__(self, unfilled): from mizani.palettes import manual_pal diff --git a/plotnine/scales/scale_size.py b/plotnine/scales/scale_size.py index 0ea58fd22..0ecfcc3da 100644 --- a/plotnine/scales/scale_size.py +++ b/plotnine/scales/scale_size.py @@ -1,10 +1,11 @@ from dataclasses import KW_ONLY, InitVar, dataclass -from typing import Literal from warnings import warn import numpy as np from mizani.bounds import rescale_max +from plotnine.scales._runtime_typing import OptionalLegend + from .._utils.registry import alias from ..exceptions import PlotnineWarning from .scale_continuous import scale_continuous @@ -13,7 +14,7 @@ @dataclass -class scale_size_ordinal(scale_discrete): +class scale_size_ordinal(scale_discrete[OptionalLegend]): """ Discrete area size scale """ @@ -23,6 +24,8 @@ class scale_size_ordinal(scale_discrete): """ Range ([Minimum, Maximum]) of the size. """ + _: KW_ONLY + guide: OptionalLegend = "legend" def __post_init__(self, range): super().__post_init__() @@ -51,7 +54,7 @@ def __post_init__(self, range): @dataclass -class scale_size_continuous(scale_continuous[Literal["legend"] | None]): +class scale_size_continuous(scale_continuous[OptionalLegend]): """ Continuous area size scale """ @@ -63,7 +66,7 @@ class scale_size_continuous(scale_continuous[Literal["legend"] | None]): """ _: KW_ONLY - guide: Literal["legend"] | None = "legend" + guide: OptionalLegend = "legend" def __post_init__(self, range): from mizani.palettes import area_pal @@ -78,7 +81,7 @@ class scale_size(scale_size_continuous): @dataclass -class scale_size_radius(scale_continuous[Literal["legend"] | None]): +class scale_size_radius(scale_continuous[OptionalLegend]): """ Continuous radius size scale """ @@ -90,7 +93,7 @@ class scale_size_radius(scale_continuous[Literal["legend"] | None]): """ _: KW_ONLY - guide: Literal["legend"] | None = "legend" + guide: OptionalLegend = "legend" def __post_init__(self, range): from mizani.palettes import rescale_pal @@ -100,7 +103,7 @@ def __post_init__(self, range): @dataclass -class scale_size_area(scale_continuous[Literal["legend"] | None]): +class scale_size_area(scale_continuous[OptionalLegend]): """ Continuous area size scale """ @@ -113,7 +116,7 @@ class scale_size_area(scale_continuous[Literal["legend"] | None]): _: KW_ONLY rescaler = rescale_max - guide: Literal["legend"] | None = "legend" + guide: OptionalLegend = "legend" def __post_init__(self, max_size): from mizani.palettes import abs_area @@ -135,7 +138,7 @@ class scale_size_datetime(scale_datetime): """ _: KW_ONLY - guide: Literal["legend"] | None = "legend" + guide: OptionalLegend = "legend" def __post_init__( self, range, date_breaks, date_labels, date_minor_breaks diff --git a/plotnine/scales/scale_stroke.py b/plotnine/scales/scale_stroke.py index 9d371430d..5f1158ce2 100644 --- a/plotnine/scales/scale_stroke.py +++ b/plotnine/scales/scale_stroke.py @@ -1,9 +1,10 @@ from dataclasses import KW_ONLY, InitVar, dataclass -from typing import Literal from warnings import warn import numpy as np +from plotnine.scales._runtime_typing import OptionalLegend + from .._utils.registry import alias from ..exceptions import PlotnineWarning from .scale_continuous import scale_continuous @@ -11,7 +12,7 @@ @dataclass -class scale_stroke_continuous(scale_continuous[Literal["legend"] | None]): +class scale_stroke_continuous(scale_continuous[OptionalLegend]): """ Continuous Stroke Scale """ @@ -23,7 +24,7 @@ class scale_stroke_continuous(scale_continuous[Literal["legend"] | None]): Should be between 0 and 1. """ _: KW_ONLY - guide: Literal["legend"] | None = "legend" + guide: OptionalLegend = "legend" def __post_init__(self, range): from mizani.palettes import rescale_pal diff --git a/plotnine/scales/scale_xy.py b/plotnine/scales/scale_xy.py index 368e776d4..2fbdc0802 100644 --- a/plotnine/scales/scale_xy.py +++ b/plotnine/scales/scale_xy.py @@ -32,11 +32,14 @@ # are intermediate base classes where the required overriding # is done @dataclass(kw_only=True) -class scale_position_discrete(scale_discrete): +class scale_position_discrete(scale_discrete[None]): """ Base class for discrete position scales """ + # All positions have no guide + guide: None = None + def __post_init__(self): super().__post_init__() # Keeps two ranges, range and range_c @@ -44,9 +47,6 @@ def __post_init__(self): if isinstance(self.limits, tuple): self.limits = list(self.limits) - # All positions have no guide - self.guide = None - def reset(self): # Can't reset discrete scale because # no way to recover values diff --git a/tests/test_guide_internals.py b/tests/test_guide_internals.py index 6aecd85ed..9205834a5 100644 --- a/tests/test_guide_internals.py +++ b/tests/test_guide_internals.py @@ -8,8 +8,9 @@ geom_bar, geom_point, ggplot, + stage, ) -from plotnine.data import mtcars +from plotnine.data import mpg, mtcars def test_no_after_scale_warning(): @@ -20,6 +21,18 @@ def test_no_after_scale_warning(): p.draw_test() # type: ignore +def test_after_scale_positional_aesthetic_with_legend(): + # A staged positional aesthetic cannot appear in the legend key + # data, so building the legend must not attempt to evaluate it + p = ggplot(mpg, aes("drv", "displ", color="drv")) + geom_point( + aes(x=stage("drv", after_scale="x")) + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + p.draw_test() # pyright: ignore[reportAttributeAccessIssue] + + def test_guide_legend_after_scale(): def alphen(series, a): ha = f"{round(a * 255):#04X}"[2:]