plotnine 0.15.0.dev2__py3-none-any.whl → 0.15.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. plotnine/__init__.py +2 -0
  2. plotnine/_mpl/layout_manager/_engine.py +1 -1
  3. plotnine/_mpl/layout_manager/_layout_items.py +128 -83
  4. plotnine/_mpl/layout_manager/_layout_tree.py +761 -310
  5. plotnine/_mpl/layout_manager/_spaces.py +320 -103
  6. plotnine/_mpl/patches.py +70 -34
  7. plotnine/_mpl/text.py +144 -63
  8. plotnine/_mpl/utils.py +1 -1
  9. plotnine/_utils/__init__.py +50 -107
  10. plotnine/_utils/context.py +78 -2
  11. plotnine/_utils/ipython.py +35 -51
  12. plotnine/_utils/quarto.py +21 -0
  13. plotnine/_utils/yippie.py +115 -0
  14. plotnine/composition/__init__.py +11 -0
  15. plotnine/composition/_beside.py +55 -0
  16. plotnine/composition/_compose.py +471 -0
  17. plotnine/composition/_plot_spacer.py +60 -0
  18. plotnine/composition/_stack.py +55 -0
  19. plotnine/coords/coord.py +3 -3
  20. plotnine/data/__init__.py +31 -0
  21. plotnine/data/anscombe-quartet.csv +45 -0
  22. plotnine/doctools.py +4 -4
  23. plotnine/facets/facet.py +4 -4
  24. plotnine/facets/strips.py +17 -28
  25. plotnine/geoms/annotate.py +13 -13
  26. plotnine/geoms/annotation_logticks.py +7 -8
  27. plotnine/geoms/annotation_stripes.py +6 -6
  28. plotnine/geoms/geom.py +60 -27
  29. plotnine/geoms/geom_abline.py +3 -2
  30. plotnine/geoms/geom_area.py +2 -2
  31. plotnine/geoms/geom_bar.py +11 -2
  32. plotnine/geoms/geom_bin_2d.py +6 -2
  33. plotnine/geoms/geom_blank.py +0 -3
  34. plotnine/geoms/geom_boxplot.py +8 -4
  35. plotnine/geoms/geom_col.py +8 -2
  36. plotnine/geoms/geom_count.py +6 -2
  37. plotnine/geoms/geom_crossbar.py +3 -3
  38. plotnine/geoms/geom_density_2d.py +6 -2
  39. plotnine/geoms/geom_dotplot.py +2 -2
  40. plotnine/geoms/geom_errorbar.py +2 -2
  41. plotnine/geoms/geom_errorbarh.py +2 -2
  42. plotnine/geoms/geom_histogram.py +1 -1
  43. plotnine/geoms/geom_hline.py +3 -2
  44. plotnine/geoms/geom_linerange.py +2 -2
  45. plotnine/geoms/geom_map.py +5 -5
  46. plotnine/geoms/geom_path.py +11 -12
  47. plotnine/geoms/geom_point.py +4 -5
  48. plotnine/geoms/geom_pointdensity.py +4 -0
  49. plotnine/geoms/geom_pointrange.py +3 -5
  50. plotnine/geoms/geom_polygon.py +2 -3
  51. plotnine/geoms/geom_qq.py +4 -0
  52. plotnine/geoms/geom_qq_line.py +4 -0
  53. plotnine/geoms/geom_quantile.py +4 -0
  54. plotnine/geoms/geom_raster.py +4 -5
  55. plotnine/geoms/geom_rect.py +3 -4
  56. plotnine/geoms/geom_ribbon.py +7 -7
  57. plotnine/geoms/geom_rug.py +1 -1
  58. plotnine/geoms/geom_segment.py +2 -2
  59. plotnine/geoms/geom_sina.py +3 -3
  60. plotnine/geoms/geom_smooth.py +7 -3
  61. plotnine/geoms/geom_step.py +2 -2
  62. plotnine/geoms/geom_text.py +2 -3
  63. plotnine/geoms/geom_violin.py +28 -8
  64. plotnine/geoms/geom_vline.py +3 -2
  65. plotnine/ggplot.py +64 -85
  66. plotnine/guides/guide.py +7 -10
  67. plotnine/guides/guide_colorbar.py +3 -3
  68. plotnine/guides/guide_legend.py +3 -3
  69. plotnine/guides/guides.py +6 -6
  70. plotnine/helpers.py +49 -0
  71. plotnine/iapi.py +28 -5
  72. plotnine/labels.py +3 -3
  73. plotnine/layer.py +36 -19
  74. plotnine/mapping/_atomic.py +178 -0
  75. plotnine/mapping/_env.py +13 -2
  76. plotnine/mapping/_eval_environment.py +85 -0
  77. plotnine/mapping/aes.py +91 -72
  78. plotnine/mapping/evaluation.py +7 -65
  79. plotnine/scales/__init__.py +2 -0
  80. plotnine/scales/limits.py +7 -7
  81. plotnine/scales/scale.py +3 -3
  82. plotnine/scales/scale_color.py +82 -18
  83. plotnine/scales/scale_continuous.py +6 -4
  84. plotnine/scales/scale_datetime.py +28 -14
  85. plotnine/scales/scale_discrete.py +1 -1
  86. plotnine/scales/scale_identity.py +21 -2
  87. plotnine/scales/scale_manual.py +8 -2
  88. plotnine/scales/scale_xy.py +2 -2
  89. plotnine/stats/binning.py +4 -1
  90. plotnine/stats/smoothers.py +23 -36
  91. plotnine/stats/stat.py +20 -32
  92. plotnine/stats/stat_bin.py +6 -5
  93. plotnine/stats/stat_bin_2d.py +11 -9
  94. plotnine/stats/stat_bindot.py +13 -16
  95. plotnine/stats/stat_boxplot.py +6 -6
  96. plotnine/stats/stat_count.py +6 -9
  97. plotnine/stats/stat_density.py +7 -10
  98. plotnine/stats/stat_density_2d.py +12 -8
  99. plotnine/stats/stat_ecdf.py +7 -6
  100. plotnine/stats/stat_ellipse.py +9 -6
  101. plotnine/stats/stat_function.py +10 -8
  102. plotnine/stats/stat_hull.py +6 -3
  103. plotnine/stats/stat_identity.py +5 -2
  104. plotnine/stats/stat_pointdensity.py +5 -7
  105. plotnine/stats/stat_qq.py +46 -20
  106. plotnine/stats/stat_qq_line.py +16 -11
  107. plotnine/stats/stat_quantile.py +15 -9
  108. plotnine/stats/stat_sina.py +45 -14
  109. plotnine/stats/stat_smooth.py +8 -10
  110. plotnine/stats/stat_sum.py +5 -2
  111. plotnine/stats/stat_summary.py +7 -10
  112. plotnine/stats/stat_summary_bin.py +11 -14
  113. plotnine/stats/stat_unique.py +5 -2
  114. plotnine/stats/stat_ydensity.py +8 -11
  115. plotnine/themes/elements/__init__.py +2 -1
  116. plotnine/themes/elements/element_line.py +17 -9
  117. plotnine/themes/elements/margin.py +64 -1
  118. plotnine/themes/theme.py +9 -1
  119. plotnine/themes/theme_538.py +0 -1
  120. plotnine/themes/theme_bw.py +0 -1
  121. plotnine/themes/theme_dark.py +0 -1
  122. plotnine/themes/theme_gray.py +6 -5
  123. plotnine/themes/theme_light.py +1 -1
  124. plotnine/themes/theme_matplotlib.py +5 -5
  125. plotnine/themes/theme_seaborn.py +7 -4
  126. plotnine/themes/theme_void.py +9 -8
  127. plotnine/themes/theme_xkcd.py +0 -1
  128. plotnine/themes/themeable.py +110 -32
  129. plotnine/typing.py +17 -6
  130. plotnine/watermark.py +3 -3
  131. {plotnine-0.15.0.dev2.dist-info → plotnine-0.15.1.dist-info}/METADATA +13 -6
  132. plotnine-0.15.1.dist-info/RECORD +221 -0
  133. {plotnine-0.15.0.dev2.dist-info → plotnine-0.15.1.dist-info}/WHEEL +1 -1
  134. plotnine/plot_composition/__init__.py +0 -10
  135. plotnine/plot_composition/_compose.py +0 -436
  136. plotnine/plot_composition/_spacer.py +0 -32
  137. plotnine-0.15.0.dev2.dist-info/RECORD +0 -214
  138. /plotnine/{plot_composition → composition}/_plotspec.py +0 -0
  139. {plotnine-0.15.0.dev2.dist-info → plotnine-0.15.1.dist-info}/licenses/LICENSE +0 -0
  140. {plotnine-0.15.0.dev2.dist-info → plotnine-0.15.1.dist-info}/top_level.txt +0 -0
plotnine/layer.py CHANGED
@@ -8,7 +8,7 @@ import pandas as pd
8
8
 
9
9
  from ._utils import array_kind, check_required_aesthetics, ninteraction
10
10
  from .exceptions import PlotnineError
11
- from .mapping.aes import NO_GROUP, SCALED_AESTHETICS, aes
11
+ from .mapping.aes import NO_GROUP, SCALED_AESTHETICS, aes, make_labels
12
12
  from .mapping.evaluation import evaluate, stage
13
13
 
14
14
  if typing.TYPE_CHECKING:
@@ -125,16 +125,16 @@ class layer:
125
125
  lkwargs[param] = geom.DEFAULT_PARAMS[param]
126
126
  return layer(**lkwargs)
127
127
 
128
- def __radd__(self, plot: ggplot) -> ggplot:
128
+ def __radd__(self, other: ggplot) -> ggplot:
129
129
  """
130
130
  Add layer to ggplot object
131
131
  """
132
132
  try:
133
- plot.layers.append(self)
133
+ other.layers.append(self)
134
134
  except AttributeError as e:
135
- msg = f"Cannot add layer to object of type {type(plot)!r}"
135
+ msg = f"Cannot add layer to object of type {type(other)!r}"
136
136
  raise PlotnineError(msg) from e
137
- return plot
137
+ return other
138
138
 
139
139
  def __deepcopy__(self, memo: dict[Any, Any]) -> layer:
140
140
  """
@@ -163,6 +163,7 @@ class layer:
163
163
  self._make_layer_data(plot.data)
164
164
  self._make_layer_mapping(plot.mapping)
165
165
  self._make_layer_environments(plot.environment)
166
+ self._share_layer_params()
166
167
 
167
168
  def _make_layer_data(self, plot_data: DataLike | None):
168
169
  """
@@ -250,6 +251,14 @@ class layer:
250
251
  self.geom.environment = plot_environment
251
252
  self.stat.environment = plot_environment
252
253
 
254
+ def _share_layer_params(self):
255
+ """
256
+ Pass necessary layer parameters to the geom
257
+ """
258
+ self.geom.params["zorder"] = self.zorder
259
+ self.geom.params["raster"] = self.raster
260
+ self.geom.params["inherit_aes"] = self.inherit_aes
261
+
253
262
  def compute_aesthetics(self, plot: ggplot):
254
263
  """
255
264
  Return a dataframe where the columns match the aesthetic mappings
@@ -278,10 +287,10 @@ class layer:
278
287
  if not len(data):
279
288
  return
280
289
 
281
- params = self.stat.setup_params(data)
290
+ self.stat.setup_params(data)
282
291
  data = self.stat.use_defaults(data)
283
292
  data = self.stat.setup_data(data)
284
- data = self.stat.compute_layer(data, params, layout)
293
+ data = self.stat.compute_layer(data, layout)
285
294
  self.data = data
286
295
 
287
296
  def map_statistic(self, plot: ggplot):
@@ -289,7 +298,9 @@ class layer:
289
298
  Mapping aesthetics to computed statistics
290
299
  """
291
300
  # Mixin default stat aesthetic mappings
292
- calculated = self.mapping.inherit(self.stat.DEFAULT_AES)._calculated
301
+ calculated = (
302
+ aes(**self.stat.DEFAULT_AES)._calculated | self.mapping._calculated
303
+ )
293
304
 
294
305
  if not len(self.data) or not calculated:
295
306
  return
@@ -320,6 +331,9 @@ class layer:
320
331
  if len(data) == 0:
321
332
  return
322
333
 
334
+ self.geom.params.update(self.stat.params)
335
+ self.geom.setup_params(data)
336
+ self.geom.setup_aes_params(data)
323
337
  data = self.geom.setup_data(data)
324
338
 
325
339
  check_required_aesthetics(
@@ -357,14 +371,10 @@ class layer:
357
371
  coord : coord
358
372
  Type of coordinate axes
359
373
  """
360
- params = copy(self.geom.params)
361
- params.update(self.stat.params)
362
- params["zorder"] = self.zorder
363
- params["raster"] = self.raster
364
374
  self.data = self.geom.handle_na(self.data)
365
375
  # At this point each layer must have the data
366
376
  # that is created by the plot build process
367
- self.geom.draw_layer(self.data, layout, coord, **params)
377
+ self.geom.draw_layer(self.data, layout, coord)
368
378
 
369
379
  def use_defaults(
370
380
  self,
@@ -399,7 +409,14 @@ class layer:
399
409
  """
400
410
  Prepare/modify data for plotting
401
411
  """
402
- self.stat.finish_layer(self.data, self.stat.params)
412
+ self.stat.finish_layer(self.data)
413
+
414
+ def update_labels(self, plot: ggplot):
415
+ """
416
+ Update label data for the ggplot from the mappings in this layer
417
+ """
418
+ plot.labels.add_defaults(self.mapping.labels)
419
+ plot.labels.add_defaults(make_labels(self.stat.DEFAULT_AES))
403
420
 
404
421
 
405
422
  class Layers(List[layer]):
@@ -450,7 +467,9 @@ class Layers(List[layer]):
450
467
  return [l.data for l in self]
451
468
 
452
469
  def setup(self, plot: ggplot):
453
- for l in self:
470
+ # If zorder is 0, it is left to MPL
471
+ for i, l in enumerate(self, start=1):
472
+ l.zorder = i
454
473
  l.setup(plot)
455
474
 
456
475
  def setup_data(self):
@@ -458,9 +477,7 @@ class Layers(List[layer]):
458
477
  l.setup_data()
459
478
 
460
479
  def draw(self, layout: Layout, coord: coord):
461
- # If zorder is 0, it is left to MPL
462
- for i, l in enumerate(self, start=1):
463
- l.zorder = i
480
+ for l in self:
464
481
  l.draw(layout, coord)
465
482
 
466
483
  def compute_aesthetics(self, plot: ggplot):
@@ -501,7 +518,7 @@ class Layers(List[layer]):
501
518
 
502
519
  def update_labels(self, plot: ggplot):
503
520
  for l in self:
504
- plot._update_labels(l)
521
+ l.update_labels(plot)
505
522
 
506
523
 
507
524
  def add_group(data: pd.DataFrame) -> pd.DataFrame:
@@ -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
plotnine/mapping/_env.py CHANGED
@@ -10,6 +10,8 @@ if TYPE_CHECKING:
10
10
  from collections.abc import Iterator
11
11
  from typing import Any, Hashable, Protocol, Self
12
12
 
13
+ from patsy.eval import EvalEnvironment
14
+
13
15
  class SupportsGetItem(Protocol):
14
16
  """
15
17
  Supports __getitem__
@@ -110,6 +112,15 @@ class Environment:
110
112
  finally:
111
113
  del frame
112
114
 
115
+ def to_patsy_env(self) -> EvalEnvironment:
116
+ """
117
+ Convert a plotnine environment to a patsy environment
118
+ """
119
+ from patsy.eval import EvalEnvironment
120
+
121
+ eval_env = EvalEnvironment(self.namespaces)
122
+ return eval_env
123
+
113
124
  def _namespace_ids(self):
114
125
  return [id(n) for n in self.namespaces]
115
126
 
@@ -122,7 +133,7 @@ class Environment:
122
133
  def __hash__(self):
123
134
  return hash((Environment, tuple(self._namespace_ids())))
124
135
 
125
- def __getstate__(*args, **kwargs):
136
+ def __getstate__(self):
126
137
  """
127
138
  Return state with no namespaces
128
139
  """
@@ -198,7 +209,7 @@ class StackedLookup(MutableMapping):
198
209
  def __repr__(self):
199
210
  return f"{self.__class__.__name__}({self.stack})"
200
211
 
201
- def __getstate__(*args, **kwargs):
212
+ def __getstate__(self):
202
213
  """
203
214
  Return state with no namespace
204
215
  """
@@ -0,0 +1,85 @@
1
+ """
2
+ These are functions that can be called by the user inside the aes()
3
+ mapping. This is meant to make it easy to transform column-variables
4
+ as easily as is possible in ggplot2.
5
+
6
+ We only implement the most common functions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ if TYPE_CHECKING:
17
+ from typing import Any, Sequence
18
+
19
+ __all__ = (
20
+ "factor",
21
+ "reorder",
22
+ )
23
+
24
+
25
+ def factor(
26
+ values: Sequence[Any],
27
+ categories: Sequence[Any] | None = None,
28
+ ordered: bool | None = None,
29
+ ) -> pd.Categorical:
30
+ """
31
+ Turn x in to a categorical (factor) variable
32
+
33
+ It is just an alias to `pandas.Categorical`
34
+
35
+ Parameters
36
+ ----------
37
+ values :
38
+ The values of the categorical. If categories are given, values not in
39
+ categories will be replaced with NaN.
40
+ categories :
41
+ The unique categories for this categorical. If not given, the
42
+ categories are assumed to be the unique values of `values`
43
+ (sorted, if possible, otherwise in the order in which they appear).
44
+ ordered :
45
+ Whether or not this categorical is treated as a ordered categorical.
46
+ If True, the resulting categorical will be ordered.
47
+ An ordered categorical respects, when sorted, the order of its
48
+ `categories` attribute (which in turn is the `categories` argument, if
49
+ provided).
50
+ """
51
+ return pd.Categorical(values, categories=categories, ordered=None) # pyright: ignore[reportArgumentType]
52
+
53
+
54
+ def reorder(x, y, fun=np.median, ascending=True):
55
+ """
56
+ Reorder categorical by sorting along another variable
57
+
58
+ It is the order of the categories that changes. Values in x
59
+ are grouped by categories and summarised to determine the
60
+ new order.
61
+
62
+ Credit: Copied from plydata
63
+
64
+ Parameters
65
+ ----------
66
+ x : list-like
67
+ Values that will make up the categorical.
68
+ y : list-like
69
+ Values by which `c` will be ordered.
70
+ fun : callable
71
+ Summarising function to `x` for each category in `c`.
72
+ Default is the *median*.
73
+ ascending : bool
74
+ If `True`, the `c` is ordered in ascending order of `x`.
75
+ """
76
+ if len(x) != len(y):
77
+ raise ValueError(f"Lengths are not equal. {len(x)=}, {len(x)=}")
78
+ summary = (
79
+ pd.Series(y)
80
+ .groupby(x, observed=True)
81
+ .apply(fun)
82
+ .sort_values(ascending=ascending)
83
+ )
84
+ cats = summary.index.to_list()
85
+ return pd.Categorical(x, categories=cats)
plotnine/mapping/aes.py CHANGED
@@ -1,20 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
- import typing
5
4
  from collections.abc import Iterable, Sequence
6
5
  from contextlib import suppress
7
6
  from copy import deepcopy
8
7
  from dataclasses import fields
9
8
  from functools import cached_property
10
- from typing import Any, Dict
9
+ from typing import TYPE_CHECKING, Any, Dict
11
10
 
11
+ import numpy as np
12
12
  import pandas as pd
13
+ from mizani._colors.utils import is_color_tuple
13
14
 
14
15
  from ..iapi import labels_view
15
16
  from .evaluation import after_stat, stage
16
17
 
17
- if typing.TYPE_CHECKING:
18
+ if TYPE_CHECKING:
18
19
  from typing import Protocol, TypeVar
19
20
 
20
21
  class ColorOrColour(Protocol):
@@ -172,27 +173,11 @@ class aes(Dict[str, Any]):
172
173
  ggplot(df, aes(x="df.index", y="np.sin(gam ma)"))
173
174
  ```
174
175
 
175
- `aes` has 2 internal methods you can use to transform variables being
176
- mapped.
176
+ `aes` has 2 internal functions that you can use in your expressions
177
+ when transforming the variables.
177
178
 
178
- 1. `factor` - This function turns the variable into a factor.
179
- It is just an alias to `pandas.Categorical`:
180
-
181
- ```python
182
- ggplot(mtcars, aes(x="factor(cyl)")) + geom_bar()
183
- ```
184
-
185
- 2. `reorder` - This function changes the order of first variable
186
- based on values of the second variable:
187
-
188
- ```python
189
- df = pd.DataFrame({
190
- "x": ["b", "d", "c", "a"],
191
- "y": [1, 2, 3, 4]
192
- })
193
-
194
- ggplot(df, aes("reorder(x, y)", "y")) + geom_col()
195
- ```
179
+ 1. [](:func:`~plotnine.mapping._eval_environment.factor`)
180
+ 1. [](:func:`~plotnine.mapping._eval_environment.reorder`)
196
181
 
197
182
  **The group aesthetic**
198
183
 
@@ -299,14 +284,20 @@ class aes(Dict[str, Any]):
299
284
 
300
285
  return result
301
286
 
302
- def __radd__(self, plot):
287
+ def __radd__(self, other):
303
288
  """
304
289
  Add aesthetic mappings to ggplot
305
290
  """
306
291
  self = deepcopy(self)
307
- plot.mapping.update(self)
308
- plot.labels.update(make_labels(self))
309
- return plot
292
+ other.mapping.update(self)
293
+ return other
294
+
295
+ @property
296
+ def labels(self) -> labels_view:
297
+ """
298
+ The labels for this mapping
299
+ """
300
+ return make_labels(self)
310
301
 
311
302
  def copy(self):
312
303
  return aes(**self)
@@ -555,23 +546,23 @@ def make_labels(mapping: dict[str, Any] | aes) -> labels_view:
555
546
  )
556
547
 
557
548
 
558
- def is_valid_aesthetic(value: Any, ae: str) -> bool:
549
+ class RepeatAesthetic:
559
550
  """
560
- Return True if `value` looks valid.
551
+ Repeat an Aeshetic a given number of times
561
552
 
562
- Parameters
563
- ----------
564
- value :
565
- Value to check
566
- ae :
567
- Aesthetic name
553
+ The methods in this class know how to create sequences of aesthetics
554
+ whose values may not be scalar.
568
555
 
569
- Notes
570
- -----
571
- There are no guarantees that he value is spot on
572
- valid.
556
+ Some aesthetics may have valid values that are not scalar. e.g.
557
+ sequences. Inserting one of such a value in a dataframe as a column
558
+ would either lead to the wrong input or fail. The s
573
559
  """
574
- if ae == "linetype":
560
+
561
+ @staticmethod
562
+ def linetype(value: Any, n: int) -> Sequence[Any]:
563
+ """
564
+ Repeat linetypes
565
+ """
575
566
  named = {
576
567
  "solid",
577
568
  "dashed",
@@ -586,47 +577,75 @@ def is_valid_aesthetic(value: Any, ae: str) -> bool:
586
577
  "",
587
578
  }
588
579
  if value in named:
589
- return True
580
+ return [value] * n
590
581
 
591
582
  # tuple of the form (offset, (on, off, on, off, ...))
592
583
  # e.g (0, (1, 2))
593
- conditions = [
594
- isinstance(value, tuple),
595
- isinstance(value[0], int),
596
- isinstance(value[1], tuple),
597
- len(value[1]) % 2 == 0,
598
- all(isinstance(x, int) for x in value[1]),
599
- ]
600
- return all(conditions)
601
-
602
- elif ae == "shape":
584
+ if (
585
+ isinstance(value, tuple)
586
+ and isinstance(value[0], int)
587
+ and isinstance(value[1], tuple)
588
+ and len(value[1]) % 2 == 0
589
+ and all(isinstance(x, int) for x in value[1])
590
+ ):
591
+ return [value] * n
592
+
593
+ raise ValueError(f"{value} is not a known linetype.")
594
+
595
+ @staticmethod
596
+ def color(value: Any, n: int) -> Sequence[Any]:
597
+ """
598
+ Repeat colors
599
+ """
603
600
  if isinstance(value, str):
604
- return True
601
+ return [value] * n
602
+ if is_color_tuple(value):
603
+ return [tuple(value)] * n
604
+
605
+ raise ValueError(f"{value} is not a known color.")
605
606
 
607
+ fill = color
608
+
609
+ @staticmethod
610
+ def shape(value: Any, n: int) -> Any:
611
+ """
612
+ Repeat shapes
613
+ """
614
+ if isinstance(value, str):
615
+ return [value] * n
606
616
  # tuple of the form (numsides, style, angle)
607
617
  # where style is in the range [0, 3]
608
618
  # e.g (4, 1, 45)
609
- conditions = [
610
- isinstance(value, tuple),
611
- all(isinstance(x, int) for x in value),
612
- 0 <= value[1] < 3,
613
- ]
614
- return all(conditions)
615
-
616
- elif ae in {"color", "fill"}:
617
- if isinstance(value, str):
618
- return True
619
- with suppress(TypeError):
620
- if isinstance(value, (tuple, list)) and all(
621
- 0 <= x <= 1 for x in value
622
- ):
623
- return True
624
- return False
619
+ if (
620
+ isinstance(value, tuple)
621
+ and all(isinstance(x, int) for x in value)
622
+ and 0 <= value[1] < 3
623
+ ):
624
+ return [value] * n
625
+
626
+ if is_shape_points(value):
627
+ return [tuple(value)] * n
628
+
629
+ raise ValueError(f"{value} is not a know shape.")
630
+
625
631
 
626
- # For any other aesthetics we return False to allow
627
- # for special cases to be discovered and then coded
628
- # for appropriately.
629
- return False
632
+ def is_shape_points(obj: Any) -> bool:
633
+ """
634
+ Return True if obj is like Sequence[tuple[float, float]]
635
+ """
636
+
637
+ def is_numeric(obj) -> bool:
638
+ """
639
+ Return True if obj is a python or numpy float or integer
640
+ """
641
+ return isinstance(obj, (float, int, np.floating, np.integer))
642
+
643
+ if not iter(obj):
644
+ return False
645
+ try:
646
+ return all(is_numeric(a) and is_numeric(b) for a, b in obj)
647
+ except TypeError:
648
+ return False
630
649
 
631
650
 
632
651
  def has_groups(data: pd.DataFrame) -> bool: