Skip to content

Commit 3f71464

Browse files
committed
Refactor handling multiple complex values
1 parent 99b9cb7 commit 3f71464

2 files changed

Lines changed: 207 additions & 17 deletions

File tree

plotnine/geoms/geom.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@
22

33
import typing
44
from abc import ABC
5+
from contextlib import suppress
56
from copy import deepcopy
67
from itertools import chain, repeat
78

9+
import numpy as np
10+
811
from .._utils import (
912
data_mapping_as_kwargs,
1013
remove_missing,
1114
)
1215
from .._utils.registry import Register, Registry
1316
from ..exceptions import PlotnineError
1417
from ..layer import layer
15-
from ..mapping.aes import RepeatAesthetic, rename_aesthetics
18+
from ..mapping.aes import rename_aesthetics
1619
from ..mapping.evaluation import evaluate
1720
from ..positions.position import position
1821
from ..stats.stat import stat
@@ -243,6 +246,9 @@ def use_defaults(
243246
:
244247
Data used for drawing the geom.
245248
"""
249+
from plotnine.mapping import _atomic as atomic
250+
from plotnine.mapping._atomic import ae_value
251+
246252
missing_aes = (
247253
self.DEFAULT_AES.keys()
248254
- self.aes_params.keys()
@@ -261,25 +267,31 @@ def use_defaults(
261267
num_panels = len(data["PANEL"].unique()) if "PANEL" in data else 1
262268
across_panels = num_panels > 1 and not self.params["inherit_aes"]
263269

264-
# Aesthetics set as parameters to the geom/stat
270+
# Aesthetics set as parameters in the geom/stat
265271
for ae, value in self.aes_params.items():
266-
try:
272+
if isinstance(value, (str, int, float, np.integer, np.floating)):
267273
data[ae] = value
268-
except ValueError as e:
269-
# NOTE: Handling of the edgecases in this exception is not
270-
# foolproof.
271-
repeat_ae = getattr(RepeatAesthetic, ae, None)
272-
if across_panels:
273-
# Adding an annotation/abline/hline/vhline with multiple
274-
# items across to more than 1 panel
275-
value = list(chain(*repeat(value, num_panels)))
274+
elif isinstance(value, ae_value):
275+
data[ae] = value * len(data)
276+
elif across_panels:
277+
value = list(chain(*repeat(value, num_panels)))
278+
data[ae] = value
279+
else:
280+
# Try to make sense of aesthetics whose values can be tuples
281+
# or sequences of sorts.
282+
ae_value_cls: type[ae_value] | None = getattr(atomic, ae, None)
283+
if ae_value_cls:
284+
with suppress(ValueError):
285+
data[ae] = ae_value_cls(value) * len(data)
286+
continue
287+
288+
# This should catch the aesthetic assignments to
289+
# non-numeric or non-string values or sequence of values.
290+
# e.g. x=datetime, x=Sequence[datetime],
291+
# x=Sequence[float], shape=Sequence[str]
292+
try:
276293
data[ae] = value
277-
elif repeat_ae:
278-
# Some aesthetics may have valid values that are not
279-
# scalar. e.g. sequences. For such case, we need to
280-
# insert a sequence of the same value.
281-
data[ae] = repeat_ae(value, len(data))
282-
else:
294+
except ValueError as e:
283295
msg = f"'{ae}={value}' does not look like a valid value"
284296
raise PlotnineError(msg) from e
285297

plotnine/mapping/_atomic.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
from __future__ import annotations
2+
3+
from contextlib import suppress
4+
from dataclasses import dataclass
5+
from typing import (
6+
Any,
7+
Generic,
8+
Literal,
9+
Sequence,
10+
TypeAlias,
11+
TypeVar,
12+
)
13+
14+
import numpy as np
15+
from mizani._colors.utils import is_color_tuple
16+
17+
# NOTE:For now we shall use these class privately and not list them
18+
# in documentation. We can't deal with assigning Sequence[ae_value]
19+
# to an aesthetic.
20+
21+
__all__ = (
22+
"linetype",
23+
"color",
24+
"colour",
25+
"fill",
26+
"shape",
27+
)
28+
29+
T = TypeVar("T")
30+
31+
ShapeType: TypeAlias = (
32+
str | tuple[int, Literal[0, 1, 2], float] | Sequence[tuple[float, float]]
33+
)
34+
35+
36+
@dataclass
37+
class ae_value(Generic[T]):
38+
"""
39+
Atomic aesthetic value
40+
41+
The goal of this base class is simplify working with the more complex
42+
aesthetic values. e.g. if a value is a tuple, we don't want it to be
43+
seen as a sequence of values when assigning it to a dataframe column.
44+
The subclasses should be able to recognise valid aesthetic values and
45+
repeat (using multiplication) the value any number of times.
46+
"""
47+
48+
value: T
49+
50+
def __mul__(self, n: int) -> Sequence[T]:
51+
"""
52+
Repeat value n times
53+
"""
54+
return [self.value] * n
55+
56+
57+
@dataclass
58+
class linetype(ae_value[str | tuple]):
59+
"""
60+
A single linetype value
61+
"""
62+
63+
def __post_init__(self):
64+
value = self.value
65+
named = {
66+
" ",
67+
"",
68+
"-",
69+
"--",
70+
"-.",
71+
":",
72+
"None",
73+
"none",
74+
"dashdot",
75+
"dashed",
76+
"dotted",
77+
"solid",
78+
}
79+
if self.value in named:
80+
return
81+
82+
# tuple of the form (offset, (on, off, on, off, ...))
83+
# e.g (0, (1, 2))
84+
if (
85+
isinstance(value, tuple)
86+
and isinstance(value[0], int)
87+
and isinstance(value[1], tuple)
88+
and len(value[1]) % 2 == 0
89+
and all(isinstance(x, int) for x in value[1])
90+
):
91+
return
92+
93+
raise ValueError(f"{value} is not a known linetype.")
94+
95+
96+
@dataclass
97+
class color(ae_value[str | tuple]):
98+
"""
99+
A single color value
100+
"""
101+
102+
def __post_init__(self):
103+
if isinstance(self.value, str):
104+
return
105+
elif is_color_tuple(self.value):
106+
self.value = tuple(self.value)
107+
return
108+
109+
raise ValueError(f"{self.value} is not a known color.")
110+
111+
112+
colour = color
113+
114+
115+
@dataclass
116+
class fill(color):
117+
"""
118+
A single color value
119+
"""
120+
121+
122+
@dataclass
123+
class shape(ae_value[ShapeType]):
124+
"""
125+
A single shape value
126+
"""
127+
128+
def __post_init__(self):
129+
from matplotlib.path import Path
130+
131+
from ..scales.scale_shape import FILLED_SHAPES, UNFILLED_SHAPES
132+
133+
value = self.value
134+
135+
with suppress(TypeError):
136+
if value in (FILLED_SHAPES | UNFILLED_SHAPES):
137+
return
138+
139+
if isinstance(value, Path):
140+
return
141+
142+
# tuple of the form (numsides, style, angle)
143+
# where style is in the range [0, 3]
144+
# e.g (4, 1, 45)
145+
if (
146+
isinstance(value, tuple)
147+
and len(value) == 3
148+
and isinstance(value[0], int)
149+
and value[1] in (0, 1, 2)
150+
and isinstance(value[2], (float, int))
151+
):
152+
return
153+
154+
if is_shape_points(value):
155+
self.value = tuple(value) # pyright: ignore[reportAttributeAccessIssue]
156+
return
157+
158+
raise ValueError(f"{value} is not a known shape.")
159+
160+
161+
def is_shape_points(obj: Any) -> bool:
162+
"""
163+
Return True if obj is like Sequence[tuple[float, float]]
164+
"""
165+
166+
def is_numeric(obj) -> bool:
167+
"""
168+
Return True if obj is a python or numpy float or integer
169+
"""
170+
return isinstance(obj, (float, int, np.floating, np.integer))
171+
172+
if not iter(obj):
173+
return False
174+
175+
try:
176+
return all(is_numeric(a) and is_numeric(b) for a, b in obj)
177+
except (ValueError, TypeError):
178+
return False

0 commit comments

Comments
 (0)