plotnine 0.15.0.dev3__py3-none-any.whl → 0.15.2__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 (139) 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 +126 -41
  4. plotnine/_mpl/layout_manager/_layout_tree.py +712 -314
  5. plotnine/_mpl/layout_manager/_spaces.py +305 -101
  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 +26 -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 +1 -0
  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 +2 -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 +8 -5
  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 +1 -1
  77. plotnine/mapping/aes.py +85 -49
  78. plotnine/scales/__init__.py +2 -0
  79. plotnine/scales/limits.py +7 -7
  80. plotnine/scales/scale.py +3 -3
  81. plotnine/scales/scale_color.py +82 -18
  82. plotnine/scales/scale_continuous.py +6 -4
  83. plotnine/scales/scale_datetime.py +28 -14
  84. plotnine/scales/scale_discrete.py +1 -1
  85. plotnine/scales/scale_identity.py +21 -2
  86. plotnine/scales/scale_manual.py +8 -2
  87. plotnine/scales/scale_xy.py +2 -2
  88. plotnine/stats/binning.py +4 -1
  89. plotnine/stats/smoothers.py +23 -36
  90. plotnine/stats/stat.py +20 -32
  91. plotnine/stats/stat_bin.py +6 -5
  92. plotnine/stats/stat_bin_2d.py +11 -9
  93. plotnine/stats/stat_bindot.py +13 -16
  94. plotnine/stats/stat_boxplot.py +6 -6
  95. plotnine/stats/stat_count.py +6 -9
  96. plotnine/stats/stat_density.py +7 -10
  97. plotnine/stats/stat_density_2d.py +12 -8
  98. plotnine/stats/stat_ecdf.py +7 -6
  99. plotnine/stats/stat_ellipse.py +9 -6
  100. plotnine/stats/stat_function.py +10 -8
  101. plotnine/stats/stat_hull.py +6 -3
  102. plotnine/stats/stat_identity.py +5 -2
  103. plotnine/stats/stat_pointdensity.py +5 -7
  104. plotnine/stats/stat_qq.py +46 -20
  105. plotnine/stats/stat_qq_line.py +16 -11
  106. plotnine/stats/stat_quantile.py +15 -9
  107. plotnine/stats/stat_sina.py +13 -15
  108. plotnine/stats/stat_smooth.py +8 -10
  109. plotnine/stats/stat_sum.py +5 -2
  110. plotnine/stats/stat_summary.py +7 -10
  111. plotnine/stats/stat_summary_bin.py +11 -14
  112. plotnine/stats/stat_unique.py +5 -2
  113. plotnine/stats/stat_ydensity.py +8 -11
  114. plotnine/themes/elements/__init__.py +2 -1
  115. plotnine/themes/elements/element_line.py +17 -9
  116. plotnine/themes/elements/margin.py +64 -1
  117. plotnine/themes/theme.py +9 -1
  118. plotnine/themes/theme_538.py +0 -1
  119. plotnine/themes/theme_bw.py +0 -1
  120. plotnine/themes/theme_dark.py +0 -1
  121. plotnine/themes/theme_gray.py +6 -5
  122. plotnine/themes/theme_light.py +1 -1
  123. plotnine/themes/theme_matplotlib.py +5 -5
  124. plotnine/themes/theme_seaborn.py +7 -4
  125. plotnine/themes/theme_void.py +9 -8
  126. plotnine/themes/theme_xkcd.py +0 -1
  127. plotnine/themes/themeable.py +109 -31
  128. plotnine/typing.py +17 -6
  129. plotnine/watermark.py +3 -3
  130. {plotnine-0.15.0.dev3.dist-info → plotnine-0.15.2.dist-info}/METADATA +13 -6
  131. plotnine-0.15.2.dist-info/RECORD +221 -0
  132. {plotnine-0.15.0.dev3.dist-info → plotnine-0.15.2.dist-info}/WHEEL +1 -1
  133. plotnine/plot_composition/__init__.py +0 -10
  134. plotnine/plot_composition/_compose.py +0 -436
  135. plotnine/plot_composition/_spacer.py +0 -32
  136. plotnine-0.15.0.dev3.dist-info/RECORD +0 -215
  137. /plotnine/{plot_composition → composition}/_plotspec.py +0 -0
  138. {plotnine-0.15.0.dev3.dist-info → plotnine-0.15.2.dist-info}/licenses/LICENSE +0 -0
  139. {plotnine-0.15.0.dev3.dist-info → plotnine-0.15.2.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
  """
@@ -48,7 +48,7 @@ def factor(
48
48
  `categories` attribute (which in turn is the `categories` argument, if
49
49
  provided).
50
50
  """
51
- return pd.Categorical(values, categories=categories, ordered=None)
51
+ return pd.Categorical(values, categories=categories, ordered=None) # pyright: ignore[reportArgumentType]
52
52
 
53
53
 
54
54
  def reorder(x, y, fun=np.median, ascending=True):
plotnine/mapping/aes.py CHANGED
@@ -8,7 +8,9 @@ from dataclasses import fields
8
8
  from functools import cached_property
9
9
  from typing import TYPE_CHECKING, Any, Dict
10
10
 
11
+ import numpy as np
11
12
  import pandas as pd
13
+ from mizani._colors.utils import is_color_tuple
12
14
 
13
15
  from ..iapi import labels_view
14
16
  from .evaluation import after_stat, stage
@@ -282,14 +284,20 @@ class aes(Dict[str, Any]):
282
284
 
283
285
  return result
284
286
 
285
- def __radd__(self, plot):
287
+ def __radd__(self, other):
286
288
  """
287
289
  Add aesthetic mappings to ggplot
288
290
  """
289
291
  self = deepcopy(self)
290
- plot.mapping.update(self)
291
- plot.labels.update(make_labels(self))
292
- 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)
293
301
 
294
302
  def copy(self):
295
303
  return aes(**self)
@@ -538,23 +546,23 @@ def make_labels(mapping: dict[str, Any] | aes) -> labels_view:
538
546
  )
539
547
 
540
548
 
541
- def is_valid_aesthetic(value: Any, ae: str) -> bool:
549
+ class RepeatAesthetic:
542
550
  """
543
- Return True if `value` looks valid.
551
+ Repeat an Aeshetic a given number of times
544
552
 
545
- Parameters
546
- ----------
547
- value :
548
- Value to check
549
- ae :
550
- Aesthetic name
553
+ The methods in this class know how to create sequences of aesthetics
554
+ whose values may not be scalar.
551
555
 
552
- Notes
553
- -----
554
- There are no guarantees that he value is spot on
555
- 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
556
559
  """
557
- if ae == "linetype":
560
+
561
+ @staticmethod
562
+ def linetype(value: Any, n: int) -> Sequence[Any]:
563
+ """
564
+ Repeat linetypes
565
+ """
558
566
  named = {
559
567
  "solid",
560
568
  "dashed",
@@ -569,47 +577,75 @@ def is_valid_aesthetic(value: Any, ae: str) -> bool:
569
577
  "",
570
578
  }
571
579
  if value in named:
572
- return True
580
+ return [value] * n
573
581
 
574
582
  # tuple of the form (offset, (on, off, on, off, ...))
575
583
  # e.g (0, (1, 2))
576
- conditions = [
577
- isinstance(value, tuple),
578
- isinstance(value[0], int),
579
- isinstance(value[1], tuple),
580
- len(value[1]) % 2 == 0,
581
- all(isinstance(x, int) for x in value[1]),
582
- ]
583
- return all(conditions)
584
-
585
- 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
+ """
586
600
  if isinstance(value, str):
587
- return True
601
+ return [value] * n
602
+ if is_color_tuple(value):
603
+ return [tuple(value)] * n
588
604
 
605
+ raise ValueError(f"{value} is not a known color.")
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
589
616
  # tuple of the form (numsides, style, angle)
590
617
  # where style is in the range [0, 3]
591
618
  # e.g (4, 1, 45)
592
- conditions = [
593
- isinstance(value, tuple),
594
- all(isinstance(x, int) for x in value),
595
- 0 <= value[1] < 3,
596
- ]
597
- return all(conditions)
598
-
599
- elif ae in {"color", "fill"}:
600
- if isinstance(value, str):
601
- return True
602
- with suppress(TypeError):
603
- if isinstance(value, (tuple, list)) and all(
604
- 0 <= x <= 1 for x in value
605
- ):
606
- return True
607
- 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
608
625
 
609
- # For any other aesthetics we return False to allow
610
- # for special cases to be discovered and then coded
611
- # for appropriately.
612
- return False
626
+ if is_shape_points(value):
627
+ return [tuple(value)] * n
628
+
629
+ raise ValueError(f"{value} is not a know shape.")
630
+
631
+
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
613
649
 
614
650
 
615
651
  def has_groups(data: pd.DataFrame) -> bool:
@@ -74,6 +74,7 @@ from .scale_identity import (
74
74
  scale_linetype_identity,
75
75
  scale_shape_identity,
76
76
  scale_size_identity,
77
+ scale_stroke_identity,
77
78
  )
78
79
 
79
80
  # linetype
@@ -217,6 +218,7 @@ __all__ = (
217
218
  "scale_linetype_identity",
218
219
  "scale_shape_identity",
219
220
  "scale_size_identity",
221
+ "scale_stroke_identity",
220
222
  # manual
221
223
  "scale_color_manual",
222
224
  "scale_colour_manual",
plotnine/scales/limits.py CHANGED
@@ -78,10 +78,10 @@ class _lim:
78
78
  self.aesthetic, series, limits=self.limits, trans=self.trans
79
79
  )
80
80
 
81
- def __radd__(self, plot):
82
- scale = self.get_scale(plot)
83
- plot.scales.append(scale)
84
- return plot
81
+ def __radd__(self, other):
82
+ scale = self.get_scale(other)
83
+ other.scales.append(scale)
84
+ return other
85
85
 
86
86
 
87
87
  class xlim(_lim):
@@ -194,7 +194,7 @@ class lims:
194
194
  def __init__(self, **kwargs):
195
195
  self._kwargs = kwargs
196
196
 
197
- def __radd__(self, plot):
197
+ def __radd__(self, other):
198
198
  """
199
199
  Add limits to ggplot object
200
200
  """
@@ -206,9 +206,9 @@ class lims:
206
206
  msg = "Cannot change limits for '{}'"
207
207
  raise PlotnineError(msg) from e
208
208
 
209
- plot += klass(value)
209
+ other += klass(value)
210
210
 
211
- return plot
211
+ return other
212
212
 
213
213
 
214
214
  def expand_limits(**kwargs):
plotnine/scales/scale.py CHANGED
@@ -148,12 +148,12 @@ class scale(
148
148
  self.aesthetics if self.aesthetics else self._aesthetics
149
149
  )
150
150
 
151
- def __radd__(self, plot):
151
+ def __radd__(self, other):
152
152
  """
153
153
  Add this scale to ggplot object
154
154
  """
155
- plot.scales.append(copy(self))
156
- return plot
155
+ other.scales.append(copy(self))
156
+ return other
157
157
 
158
158
  def map(self, x, limits=None):
159
159
  """