plotnine 0.15.0.dev2__py3-none-any.whl → 0.15.0.dev3__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.
@@ -299,40 +299,6 @@ class LayoutItems:
299
299
 
300
300
  return chain(major, minor)
301
301
 
302
- def axis_text_x_margin(self, ax: Axes) -> Iterator[float]:
303
- """
304
- Return XTicks paddings
305
- """
306
- # In plotnine tick padding are specified as a margin to the
307
- # the axis_text.
308
- major, minor = [], []
309
- if not self._is_blank("axis_text_x"):
310
- h = self.plot.figure.bbox.height
311
- major = [
312
- (t.get_pad() or 0) / h for t in ax.xaxis.get_major_ticks()
313
- ]
314
- minor = [
315
- (t.get_pad() or 0) / h for t in ax.xaxis.get_minor_ticks()
316
- ]
317
- return chain(major, minor)
318
-
319
- def axis_text_y_margin(self, ax: Axes) -> Iterator[float]:
320
- """
321
- Return YTicks paddings
322
- """
323
- # In plotnine tick padding are specified as a margin to the
324
- # the axis_text.
325
- major, minor = [], []
326
- if not self._is_blank("axis_text_y"):
327
- w = self.plot.figure.bbox.width
328
- major = [
329
- (t.get_pad() or 0) / w for t in ax.yaxis.get_major_ticks()
330
- ]
331
- minor = [
332
- (t.get_pad() or 0) / w for t in ax.yaxis.get_minor_ticks()
333
- ]
334
- return chain(major, minor)
335
-
336
302
  def strip_text_x_height(self, position: StripPosition) -> float:
337
303
  """
338
304
  Height taken up by the top strips
@@ -377,10 +343,7 @@ class LayoutItems:
377
343
  Return maximum height[figure space] of x tick labels
378
344
  """
379
345
  heights = [
380
- self.calc.tight_height(label) + pad
381
- for label, pad in zip(
382
- self.axis_text_x(ax), self.axis_text_x_margin(ax)
383
- )
346
+ self.calc.tight_height(label) for label in self.axis_text_x(ax)
384
347
  ]
385
348
  return max(heights) if len(heights) else 0
386
349
 
@@ -410,10 +373,7 @@ class LayoutItems:
410
373
  Return maximum width[figure space] of y tick labels
411
374
  """
412
375
  widths = [
413
- self.calc.tight_width(label) + pad
414
- for label, pad in zip(
415
- self.axis_text_y(ax), self.axis_text_y_margin(ax)
416
- )
376
+ self.calc.tight_width(label) for label in self.axis_text_y(ax)
417
377
  ]
418
378
  return max(widths) if len(widths) else 0
419
379
 
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import abc
4
- from contextlib import suppress
5
4
  from dataclasses import dataclass
6
5
  from functools import cached_property
7
6
  from typing import TYPE_CHECKING
@@ -81,6 +80,13 @@ class LayoutTree:
81
80
  represents.
82
81
  """
83
82
 
83
+ @cached_property
84
+ def sub_compositions(self) -> list[LayoutTree]:
85
+ """
86
+ LayoutTrees of the direct sub compositions of this one
87
+ """
88
+ return [item for item in self.nodes if isinstance(item, LayoutTree)]
89
+
84
90
  @cached_property
85
91
  @abc.abstractmethod
86
92
  def lefts(self) -> Sequence[float]:
@@ -141,89 +147,54 @@ class LayoutTree:
141
147
  In figure dimenstions
142
148
  """
143
149
 
144
- def align_lefts(self):
150
+ @abc.abstractmethod
151
+ def align(self):
145
152
  """
146
- Align the immediate left edges in this composition
153
+ Align all the edges in this composition & contained compositions
147
154
 
148
- ----------- -----------
149
- |# | | # |
150
- |# | | # |
151
- |# | | # |
152
- |-----------| -> |-----------|
153
- | # | | # |
154
- | # | | # |
155
- | # | | # |
156
- ----------- -----------
155
+ This function mutates the layout spaces, specifically the
156
+ alignment_margins along the sides of the plot.
157
157
  """
158
158
 
159
- def align_bottoms(self):
159
+ def align_sub_compositions(self):
160
160
  """
161
- Align the immediate bottom edges this composition
162
-
163
- ----------- -----------
164
- | | | | | |
165
- | | | | | |
166
- | | | -> | | |
167
- | |#####| |#####|#####|
168
- |#####| | | | |
169
- ----------- -----------
161
+ Align the compositions contained in this one
170
162
  """
163
+ # Recurse into the contained compositions
164
+ for tree in self.sub_compositions:
165
+ tree.align()
171
166
 
172
- def align_rights(self):
167
+ @property
168
+ def bottoms_align(self) -> bool:
173
169
  """
174
- Align the immediate right edges in this composition
175
-
176
- ----------- -----------
177
- | # | | # |
178
- | # | | # |
179
- | # | | # |
180
- |-----------| -> |-----------|
181
- | #| | # |
182
- | #| | # |
183
- | #| | # |
184
- ----------- -----------
170
+ Return True if panel bottoms for the nodes are aligned
185
171
  """
172
+ arr = np.array(self.bottoms)
173
+ return all(arr == arr[0])
186
174
 
187
- def align_tops(self):
175
+ @property
176
+ def lefts_align(self) -> bool:
188
177
  """
189
- Align the immediate top edges in this composition
190
-
191
- ----------- -----------
192
- |#####| | | | |
193
- | |#####| |#####|#####|
194
- | | | -> | | |
195
- | | | | | |
196
- | | | | | |
197
- ----------- -----------
178
+ Return True if panel lefts for the nodes are aligned
198
179
  """
180
+ arr = np.array(self.lefts)
181
+ return all(arr == arr[0])
199
182
 
200
- def align(self):
183
+ @property
184
+ def tops_align(self) -> bool:
201
185
  """
202
- Align all the edges in this composition & contained compositions
203
-
204
- This function mutates the layout spaces, specifically the
205
- alignment_margins along the sides of the plot.
186
+ Return True if panel tops for the nodes are aligned
206
187
  """
207
- self.align_lefts()
208
- self.align_bottoms()
209
- self.align_rights()
210
- self.align_tops()
211
-
212
- for item in self.nodes:
213
- if isinstance(item, LayoutTree):
214
- item.align()
215
-
216
- with suppress(AttributeError):
217
- del self.lefts
218
-
219
- with suppress(AttributeError):
220
- del self.bottoms
188
+ arr = np.array(self.tops)
189
+ return all(arr == arr[0])
221
190
 
222
- with suppress(AttributeError):
223
- del self.rights
224
-
225
- with suppress(AttributeError):
226
- del self.tops
191
+ @property
192
+ def rights_align(self) -> bool:
193
+ """
194
+ Return True if panel rights for the nodes are aligned
195
+ """
196
+ arr = np.array(self.rights)
197
+ return all(arr == arr[0])
227
198
 
228
199
  @property
229
200
  @abc.abstractmethod
@@ -288,22 +259,13 @@ class LayoutTree:
288
259
  This function mutates the composition gridspecs; specifically the
289
260
  width_ratios and height_ratios.
290
261
  """
291
- self.resize_widths()
292
- self.resize_heights()
293
262
 
294
- for item in self.nodes:
295
- if isinstance(item, LayoutTree):
296
- item.resize()
297
-
298
- def resize_widths(self):
263
+ def resize_sub_compositions(self):
299
264
  """
300
- Resize the widths of gridspec so that panels have equal widths
301
- """
302
-
303
- def resize_heights(self):
304
- """
305
- Resize the heights of gridspec so that panels have equal heights
265
+ Resize panels in the compositions contained in this one
306
266
  """
267
+ for tree in self.sub_compositions:
268
+ tree.resize()
307
269
 
308
270
  @staticmethod
309
271
  def create(
@@ -368,6 +330,11 @@ class ColumnsTree(LayoutTree):
368
330
  -------------------
369
331
  """
370
332
 
333
+ def align(self):
334
+ self.align_tops()
335
+ self.align_bottoms()
336
+ self.align_sub_compositions()
337
+
371
338
  @cached_property
372
339
  def lefts(self):
373
340
  left_item = self.nodes[0]
@@ -383,14 +350,6 @@ class ColumnsTree(LayoutTree):
383
350
  else:
384
351
  left_item.set_left_alignment_margin(value)
385
352
 
386
- def align_bottoms(self):
387
- values = max(self.bottoms) - np.array(self.bottoms)
388
- for item, value in zip(self.nodes, values):
389
- if isinstance(item, LayoutSpaces):
390
- item.b.alignment_margin = value
391
- else:
392
- item.set_bottom_alignment_margin(value)
393
-
394
353
  @cached_property
395
354
  def bottoms(self):
396
355
  values = []
@@ -401,6 +360,33 @@ class ColumnsTree(LayoutTree):
401
360
  values.append(max(item.bottoms))
402
361
  return values
403
362
 
363
+ def align_bottoms(self):
364
+ """
365
+ Align the immediate bottom edges this composition
366
+
367
+ ----------- -----------
368
+ | | | | | |
369
+ | | | | | |
370
+ | | | -> | | |
371
+ | |#####| |#####|#####|
372
+ |#####| | | | |
373
+ ----------- -----------
374
+ """
375
+ # If panels are aligned and have a non-zero alignment_margin,
376
+ # aligning them again will set that value to zero and undoes
377
+ # the alignment.
378
+ if self.bottoms_align:
379
+ return
380
+
381
+ values = max(self.bottoms) - np.array(self.bottoms)
382
+ for item, value in zip(self.nodes, values):
383
+ if isinstance(item, LayoutSpaces):
384
+ item.b.alignment_margin = value
385
+ else:
386
+ item.set_bottom_alignment_margin(value)
387
+
388
+ del self.bottoms
389
+
404
390
  def set_bottom_alignment_margin(self, value: float):
405
391
  for item in self.nodes:
406
392
  if isinstance(item, LayoutSpaces):
@@ -423,14 +409,6 @@ class ColumnsTree(LayoutTree):
423
409
  else:
424
410
  right_item.set_right_alignment_margin(value)
425
411
 
426
- def align_tops(self):
427
- values = np.array(self.tops) - min(self.tops)
428
- for item, value in zip(self.nodes, values):
429
- if isinstance(item, LayoutSpaces):
430
- item.t.alignment_margin = value
431
- else:
432
- item.set_top_alignment_margin(value)
433
-
434
412
  @cached_property
435
413
  def tops(self):
436
414
  values = []
@@ -441,6 +419,30 @@ class ColumnsTree(LayoutTree):
441
419
  values.append(min(item.tops))
442
420
  return values
443
421
 
422
+ def align_tops(self):
423
+ """
424
+ Align the immediate top edges in this composition
425
+
426
+ ----------- -----------
427
+ |#####| | | | |
428
+ | |#####| |#####|#####|
429
+ | | | -> | | |
430
+ | | | | | |
431
+ | | | | | |
432
+ ----------- -----------
433
+ """
434
+ if self.tops_align:
435
+ return
436
+
437
+ values = np.array(self.tops) - min(self.tops)
438
+ for item, value in zip(self.nodes, values):
439
+ if isinstance(item, LayoutSpaces):
440
+ item.t.alignment_margin = value
441
+ else:
442
+ item.set_top_alignment_margin(value)
443
+
444
+ del self.tops
445
+
444
446
  def set_top_alignment_margin(self, value: float):
445
447
  for item in self.nodes:
446
448
  if isinstance(item, LayoutSpaces):
@@ -476,7 +478,10 @@ class ColumnsTree(LayoutTree):
476
478
  """
477
479
  return max(self.plot_heights)
478
480
 
479
- def resize_widths(self):
481
+ def resize(self):
482
+ """
483
+ Resize the widths of gridspec so that panels have equal widths
484
+ """
480
485
  # The new width of each panel is the average width of all
481
486
  # the panels plus all the space to the left and right
482
487
  # of the panels.
@@ -486,6 +491,7 @@ class ColumnsTree(LayoutTree):
486
491
  new_plot_widths = panel_widths.mean() + non_panel_space
487
492
  width_ratios = new_plot_widths / new_plot_widths.min()
488
493
  self.gridspec.set_width_ratios(width_ratios)
494
+ self.resize_sub_compositions()
489
495
 
490
496
 
491
497
  @dataclass
@@ -506,13 +512,10 @@ class RowsTree(LayoutTree):
506
512
  -------------------
507
513
  """
508
514
 
509
- def align_lefts(self):
510
- values = max(self.lefts) - np.array(self.lefts)
511
- for item, value in zip(self.nodes, values):
512
- if isinstance(item, LayoutSpaces):
513
- item.l.alignment_margin = value
514
- else:
515
- item.set_left_alignment_margin(value)
515
+ def align(self):
516
+ self.align_lefts()
517
+ self.align_rights()
518
+ self.align_sub_compositions()
516
519
 
517
520
  @cached_property
518
521
  def lefts(self):
@@ -524,6 +527,32 @@ class RowsTree(LayoutTree):
524
527
  values.append(max(item.lefts))
525
528
  return values
526
529
 
530
+ def align_lefts(self):
531
+ """
532
+ Align the immediate left edges in this composition
533
+
534
+ ----------- -----------
535
+ |# | | # |
536
+ |# | | # |
537
+ |# | | # |
538
+ |-----------| -> |-----------|
539
+ | # | | # |
540
+ | # | | # |
541
+ | # | | # |
542
+ ----------- -----------
543
+ """
544
+ if self.lefts_align:
545
+ return
546
+
547
+ values = max(self.lefts) - np.array(self.lefts)
548
+ for item, value in zip(self.nodes, values):
549
+ if isinstance(item, LayoutSpaces):
550
+ item.l.alignment_margin = value
551
+ else:
552
+ item.set_left_alignment_margin(value)
553
+
554
+ del self.lefts
555
+
527
556
  def set_left_alignment_margin(self, value: float):
528
557
  for item in self.nodes:
529
558
  if isinstance(item, LayoutSpaces):
@@ -546,14 +575,6 @@ class RowsTree(LayoutTree):
546
575
  else:
547
576
  bottom_item.set_bottom_alignment_margin(value)
548
577
 
549
- def align_rights(self):
550
- values = np.array(self.rights) - min(self.rights)
551
- for item, value in zip(self.nodes, values):
552
- if isinstance(item, LayoutSpaces):
553
- item.r.alignment_margin = value
554
- else:
555
- item.set_right_alignment_margin(value)
556
-
557
578
  @cached_property
558
579
  def rights(self):
559
580
  values = []
@@ -564,6 +585,32 @@ class RowsTree(LayoutTree):
564
585
  values.append(min(item.rights))
565
586
  return values
566
587
 
588
+ def align_rights(self):
589
+ """
590
+ Align the immediate right edges in this composition
591
+
592
+ ----------- -----------
593
+ | # | | # |
594
+ | # | | # |
595
+ | # | | # |
596
+ |-----------| -> |-----------|
597
+ | #| | # |
598
+ | #| | # |
599
+ | #| | # |
600
+ ----------- -----------
601
+ """
602
+ if self.rights_align:
603
+ return
604
+
605
+ values = np.array(self.rights) - min(self.rights)
606
+ for item, value in zip(self.nodes, values):
607
+ if isinstance(item, LayoutSpaces):
608
+ item.r.alignment_margin = value
609
+ else:
610
+ item.set_right_alignment_margin(value)
611
+
612
+ del self.rights
613
+
567
614
  def set_right_alignment_margin(self, value: float):
568
615
  for item in self.nodes:
569
616
  if isinstance(item, LayoutSpaces):
@@ -614,8 +661,13 @@ class RowsTree(LayoutTree):
614
661
  """
615
662
  return sum(self.plot_heights)
616
663
 
617
- def resize_heights(self):
618
- # The new width of each panel is the average width of all
664
+ def resize(self):
665
+ """
666
+ Resize the heights of gridspec so that panels have equal heights
667
+
668
+ This method resizes (recursively) the contained compositions
669
+ """
670
+ # The new height of each panel is the average width of all
619
671
  # the panels plus all the space above and below the panels.
620
672
  plot_heights = np.array(self.plot_heights)
621
673
  panel_heights = np.array(self.panel_heights)
@@ -623,3 +675,4 @@ class RowsTree(LayoutTree):
623
675
  new_plot_heights = panel_heights.mean() + non_panel_space
624
676
  height_ratios = new_plot_heights / new_plot_heights.max()
625
677
  self.gridspec.set_height_ratios(height_ratios)
678
+ self.resize_sub_compositions()
@@ -222,7 +222,9 @@ class left_spaces(_side_spaces):
222
222
  axis_title_y_margin_left: float = 0
223
223
  axis_title_y: float = 0
224
224
  axis_title_y_margin_right: float = 0
225
+ axis_text_y_margin_left: float = 0
225
226
  axis_text_y: float = 0
227
+ axis_text_y_margin_right: float = 0
226
228
  axis_ticks_y: float = 0
227
229
 
228
230
  def _calculate(self):
@@ -256,6 +258,11 @@ class left_spaces(_side_spaces):
256
258
 
257
259
  # Account for the space consumed by the axis
258
260
  self.axis_text_y = items.axis_text_y_max_width_at("first_col")
261
+ if self.axis_text_y:
262
+ m = theme.get_margin("axis_text_y").fig
263
+ self.axis_text_y_margin_left = m.l
264
+ self.axis_text_y_margin_right = m.r
265
+
259
266
  self.axis_ticks_y = items.axis_ticks_y_max_width_at("first_col")
260
267
 
261
268
  # Adjust plot_margin to make room for ylabels that protude well
@@ -576,7 +583,9 @@ class bottom_spaces(_side_spaces):
576
583
  axis_title_x_margin_bottom: float = 0
577
584
  axis_title_x: float = 0
578
585
  axis_title_x_margin_top: float = 0
586
+ axis_text_x_margin_bottom: float = 0
579
587
  axis_text_x: float = 0
588
+ axis_text_x_margin_top: float = 0
580
589
  axis_ticks_x: float = 0
581
590
 
582
591
  def _calculate(self):
@@ -616,8 +625,12 @@ class bottom_spaces(_side_spaces):
616
625
  self.axis_title_x_margin_top = m.t * F
617
626
 
618
627
  # Account for the space consumed by the axis
619
- self.axis_ticks_x = items.axis_ticks_x_max_height_at("last_row")
620
628
  self.axis_text_x = items.axis_text_x_max_height_at("last_row")
629
+ if self.axis_text_x:
630
+ m = theme.get_margin("axis_text_x").fig
631
+ self.axis_text_x_margin_bottom = m.b
632
+ self.axis_text_x_margin_top = m.t
633
+ self.axis_ticks_x = items.axis_ticks_x_max_height_at("last_row")
621
634
 
622
635
  # Adjust plot_margin to make room for ylabels that protude well
623
636
  # beyond the axes
@@ -736,7 +749,7 @@ class LayoutSpaces:
736
749
  sw: float = field(init=False, default=0)
737
750
  """vertical spacing btn panels w.r.t figure"""
738
751
 
739
- gsparams: GridSpecParams = field(init=False)
752
+ gsparams: GridSpecParams = field(init=False, repr=False)
740
753
  """Grid spacing btn panels w.r.t figure"""
741
754
 
742
755
  def __post_init__(self):
@@ -20,6 +20,11 @@ class geom_bar(geom_rect):
20
20
  Parameters
21
21
  ----------
22
22
  {common_parameters}
23
+ just : float, default=0.5
24
+ How to align the column with respect to the axis breaks. The default
25
+ `0.5` aligns the center of the column with the break. `0` aligns the
26
+ left of the of the column with the break and `1` aligns the right of
27
+ the column with the break.
23
28
  width : float, default=None
24
29
  Bar width. If `None`{.py}, the width is set to
25
30
  `90%` of the resolution of the data.
@@ -35,6 +40,7 @@ class geom_bar(geom_rect):
35
40
  "stat": "count",
36
41
  "position": "stack",
37
42
  "na_rm": False,
43
+ "just": 0.5,
38
44
  "width": None,
39
45
  }
40
46
 
@@ -45,6 +51,8 @@ class geom_bar(geom_rect):
45
51
  else:
46
52
  data["width"] = resolution(data["x"], False) * 0.9
47
53
 
54
+ just = self.params.get("just", 0.5)
55
+
48
56
  bool_idx = data["y"] < 0
49
57
 
50
58
  data["ymin"] = 0.0
@@ -53,7 +61,7 @@ class geom_bar(geom_rect):
53
61
  data["ymax"] = data["y"]
54
62
  data.loc[bool_idx, "ymax"] = 0.0
55
63
 
56
- data["xmin"] = data["x"] - data["width"] / 2
57
- data["xmax"] = data["x"] + data["width"] / 2
64
+ data["xmin"] = data["x"] - data["width"] * just
65
+ data["xmax"] = data["x"] + data["width"] * (1 - just)
58
66
  del data["width"]
59
67
  return data
@@ -17,6 +17,11 @@ class geom_col(geom_bar):
17
17
  Parameters
18
18
  ----------
19
19
  {common_parameters}
20
+ just : float, default=0.5
21
+ How to align the column with respect to the axis breaks. The default
22
+ `0.5` aligns the center of the column with the break. `0` aligns the
23
+ left of the of the column with the break and `1` aligns the right of
24
+ the column with the break.
20
25
  width : float, default=None
21
26
  Bar width. If `None`{.py}, the width is set to
22
27
  `90%` of the resolution of the data.
@@ -32,5 +37,6 @@ class geom_col(geom_bar):
32
37
  "stat": "identity",
33
38
  "position": "stack",
34
39
  "na_rm": False,
40
+ "just": 0.5,
35
41
  "width": None,
36
42
  }
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- import typing
3
+ from typing import TYPE_CHECKING, cast
4
4
 
5
5
  import numpy as np
6
6
  import pandas as pd
@@ -11,7 +11,7 @@ from .geom import geom
11
11
  from .geom_path import geom_path
12
12
  from .geom_polygon import geom_polygon
13
13
 
14
- if typing.TYPE_CHECKING:
14
+ if TYPE_CHECKING:
15
15
  from typing import Any
16
16
 
17
17
  from matplotlib.axes import Axes
@@ -115,10 +115,17 @@ class geom_violin(geom):
115
115
  ax: Axes,
116
116
  **params: Any,
117
117
  ):
118
- quantiles = params["draw_quantiles"]
119
- style = params["style"]
118
+ quantiles = params.pop("draw_quantiles")
119
+ style = params.pop("style")
120
+ zorder = params.pop("zorder")
121
+
122
+ for i, (group, df) in enumerate(data.groupby("group")):
123
+ # Place the violins with the smalleer group number on top
124
+ # of those with larger numbers. The group_zorder values should be
125
+ # in the range [zorder, zorder + 1) to stay within the layer.
126
+ group = cast("int", group)
127
+ group_zorder = zorder + 0.9 / group
120
128
 
121
- for i, (_, df) in enumerate(data.groupby("group")):
122
129
  # Find the points for the line to go all the way around
123
130
  df["xminv"] = df["x"] - df["violinwidth"] * (df["x"] - df["xmin"])
124
131
  df["xmaxv"] = df["x"] + df["violinwidth"] * (df["xmax"] - df["x"])
@@ -156,7 +163,12 @@ class geom_violin(geom):
156
163
 
157
164
  # plot violin polygon
158
165
  geom_polygon.draw_group(
159
- polygon_df, panel_params, coord, ax, **params
166
+ polygon_df,
167
+ panel_params,
168
+ coord,
169
+ ax,
170
+ zorder=group_zorder,
171
+ **params,
160
172
  )
161
173
 
162
174
  if quantiles is not None:
@@ -174,7 +186,12 @@ class geom_violin(geom):
174
186
 
175
187
  # plot quantile segments
176
188
  geom_path.draw_group(
177
- segment_df, panel_params, coord, ax, **params
189
+ segment_df,
190
+ panel_params,
191
+ coord,
192
+ ax,
193
+ zorder=group_zorder,
194
+ **params,
178
195
  )
179
196
 
180
197
 
@@ -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)
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,19 @@
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
 
12
11
  import pandas as pd
13
12
 
14
13
  from ..iapi import labels_view
15
14
  from .evaluation import after_stat, stage
16
15
 
17
- if typing.TYPE_CHECKING:
16
+ if TYPE_CHECKING:
18
17
  from typing import Protocol, TypeVar
19
18
 
20
19
  class ColorOrColour(Protocol):
@@ -172,27 +171,11 @@ class aes(Dict[str, Any]):
172
171
  ggplot(df, aes(x="df.index", y="np.sin(gam ma)"))
173
172
  ```
174
173
 
175
- `aes` has 2 internal methods you can use to transform variables being
176
- mapped.
174
+ `aes` has 2 internal functions that you can use in your expressions
175
+ when transforming the variables.
177
176
 
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
- ```
177
+ 1. [](:func:`~plotnine.mapping._eval_environment.factor`)
178
+ 1. [](:func:`~plotnine.mapping._eval_environment.reorder`)
196
179
 
197
180
  **The group aesthetic**
198
181
 
@@ -1,15 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import numbers
4
- import typing
4
+ from typing import TYPE_CHECKING
5
5
 
6
6
  import numpy as np
7
7
  import pandas as pd
8
8
  import pandas.api.types as pdtypes
9
9
 
10
10
  from ..exceptions import PlotnineError
11
+ from ._eval_environment import factor, reorder
11
12
 
12
- if typing.TYPE_CHECKING:
13
+ if TYPE_CHECKING:
13
14
  from typing import Any
14
15
 
15
16
  from . import aes
@@ -18,6 +19,9 @@ if typing.TYPE_CHECKING:
18
19
 
19
20
  __all__ = ("after_stat", "after_scale", "stage")
20
21
 
22
+
23
+ EVAL_ENVIRONMENT = {"factor": factor, "reorder": reorder}
24
+
21
25
  _TPL_EVAL_FAIL = """\
22
26
  Could not evaluate the '{}' mapping: '{}' \
23
27
  (original error: {})"""
@@ -108,68 +112,6 @@ def after_scale(x):
108
112
  return stage(after_scale=x)
109
113
 
110
114
 
111
- def reorder(x, y, fun=np.median, ascending=True):
112
- """
113
- Reorder categorical by sorting along another variable
114
-
115
- It is the order of the categories that changes. Values in x
116
- are grouped by categories and summarised to determine the
117
- new order.
118
-
119
- Credit: Copied from plydata
120
-
121
- Parameters
122
- ----------
123
- x : list-like
124
- Values that will make up the categorical.
125
- y : list-like
126
- Values by which `c` will be ordered.
127
- fun : callable
128
- Summarising function to `x` for each category in `c`.
129
- Default is the *median*.
130
- ascending : bool
131
- If `True`, the `c` is ordered in ascending order of `x`.
132
-
133
- Examples
134
- --------
135
- >>> c = list('abbccc')
136
- >>> x = [11, 2, 2, 3, 33, 3]
137
- >>> cat_reorder(c, x)
138
- [a, b, b, c, c, c]
139
- Categories (3, object): [b, c, a]
140
- >>> cat_reorder(c, x, fun=max)
141
- [a, b, b, c, c, c]
142
- Categories (3, object): [b, a, c]
143
- >>> cat_reorder(c, x, fun=max, ascending=False)
144
- [a, b, b, c, c, c]
145
- Categories (3, object): [c, a, b]
146
- >>> c_ordered = pd.Categorical(c, ordered=True)
147
- >>> cat_reorder(c_ordered, x)
148
- [a, b, b, c, c, c]
149
- Categories (3, object): [b < c < a]
150
- >>> cat_reorder(c + ['d'], x)
151
- Traceback (most recent call last):
152
- ...
153
- ValueError: Lengths are not equal. len(c) is 7 and len(x) is 6.
154
- """
155
- if len(x) != len(y):
156
- raise ValueError(f"Lengths are not equal. {len(x)=}, {len(x)=}")
157
- summary = (
158
- pd.Series(y)
159
- .groupby(x, observed=True)
160
- .apply(fun)
161
- .sort_values(ascending=ascending)
162
- )
163
- cats = summary.index.to_list()
164
- return pd.Categorical(x, categories=cats)
165
-
166
-
167
- # These are function that can be called by the user inside the aes()
168
- # mapping. This is meant to make the variable transformations as easy
169
- # as they are in ggplot2
170
- AES_INNER_NAMESPACE = {"factor": pd.Categorical, "reorder": reorder}
171
-
172
-
173
115
  def evaluate(
174
116
  aesthetics: aes | dict[str, Any], data: pd.DataFrame, env: Environment
175
117
  ) -> pd.DataFrame:
@@ -207,7 +149,7 @@ def evaluate(
207
149
  3 16
208
150
  4 25
209
151
  """
210
- env = env.with_outer_namespace(AES_INNER_NAMESPACE)
152
+ env = env.with_outer_namespace(EVAL_ENVIRONMENT)
211
153
 
212
154
  # Store evaluation results in a dict column in a dict
213
155
  evaled = {}
@@ -57,6 +57,15 @@ class stat_sina(stat):
57
57
  - `area` - Scale by the largest density/bin among the different sinas
58
58
  - `count` - areas are scaled proportionally to the number of points
59
59
  - `width` - Only scale according to the maxwidth parameter.
60
+ style :
61
+ Type of sina plot to draw. The options are
62
+ ```python
63
+ 'full' # Regular (2 sided)
64
+ 'left' # Left-sided half
65
+ 'right' # Right-sided half
66
+ 'left-right' # Alternate (left first) half by the group
67
+ 'right-left' # Alternate (right first) half by the group
68
+ ```
60
69
 
61
70
  See Also
62
71
  --------
@@ -91,6 +100,7 @@ class stat_sina(stat):
91
100
  "bin_limit": 1,
92
101
  "random_state": None,
93
102
  "scale": "area",
103
+ "style": "full",
94
104
  }
95
105
  CREATES = {"scaled"}
96
106
 
@@ -245,6 +255,29 @@ class stat_sina(stat):
245
255
 
246
256
  def finish_layer(self, data, params):
247
257
  # Rescale x in case positions have been adjusted
258
+ style = params["style"]
259
+ x_mean = data["x"].to_numpy()
248
260
  x_mod = (data["xmax"] - data["xmin"]) / data["width"]
249
261
  data["x"] = data["x"] + data["x_diff"] * x_mod
262
+ x = data["x"].to_numpy()
263
+ even = data["group"].to_numpy() % 2 == 0
264
+
265
+ def mirror_x(bool_idx):
266
+ """
267
+ Mirror x locations along the mean value
268
+ """
269
+ data.loc[bool_idx, "x"] = (
270
+ 2 * x_mean[bool_idx] - data.loc[bool_idx, "x"]
271
+ )
272
+
273
+ match style:
274
+ case "left":
275
+ mirror_x(x_mean < x)
276
+ case "right":
277
+ mirror_x(x < x_mean)
278
+ case "left-right":
279
+ mirror_x(even & (x < x_mean) | ~even & (x_mean < x))
280
+ case "right-left":
281
+ mirror_x(even & (x_mean < x) | ~even & (x < x_mean))
282
+
250
283
  return data
@@ -1080,7 +1080,7 @@ class axis_ticks_minor_x(MixinSequenceOfValues):
1080
1080
  # to invisible. Theming should not change those artists to visible,
1081
1081
  # so we return early.
1082
1082
  params = ax.xaxis.get_tick_params(which="minor")
1083
- if not params.get("left", False):
1083
+ if not params.get("bottom", False):
1084
1084
  return
1085
1085
 
1086
1086
  # We have to use both
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plotnine
3
- Version: 0.15.0.dev2
3
+ Version: 0.15.0.dev3
4
4
  Summary: A Grammar of Graphics for Python
5
5
  Author-email: Hassan Kibirige <has2k1@gmail.com>
6
6
  License: The MIT License (MIT)
@@ -21,9 +21,9 @@ plotnine/_mpl/transforms.py,sha256=DNaOlNq76xlT696sN8ot1bmYyp4mmrjXQHk3kTi4HIg,7
21
21
  plotnine/_mpl/utils.py,sha256=c9wkxxUEweDg6O0hdgZonNOFncwxkqFye8_bL7td7I4,4131
22
22
  plotnine/_mpl/layout_manager/__init__.py,sha256=IXpPF5Oycc45uFpK4MJ6kcQCe1u5VUfnHLNZGcnrJCg,157
23
23
  plotnine/_mpl/layout_manager/_engine.py,sha256=ESUvbLAlZApFbBi6w7gZA7S4guS0Rmrj-us-gzYP2ZM,2809
24
- plotnine/_mpl/layout_manager/_layout_items.py,sha256=9wkXgCa66yCCYtpF_e0n0ORGuGGwDmEmPnNrkv-QbEg,28360
25
- plotnine/_mpl/layout_manager/_layout_tree.py,sha256=Cm2-udh5DQ8cxIZwefRkRp1ddgtJp8kvMYKeWBasHVU,18061
26
- plotnine/_mpl/layout_manager/_spaces.py,sha256=8ydStHv0O1b6JBdtBXbHkSrGzklMgutMUfgwB0lH_Dk,31866
24
+ plotnine/_mpl/layout_manager/_layout_items.py,sha256=QcO6tiDhDvhU10Y1eBP1qQ5fZhPGEsbwLYmIftxXzkU,27007
25
+ plotnine/_mpl/layout_manager/_layout_tree.py,sha256=4B2WMhIYrbikG1P2K87KqB4PqbU40zxlOAiQwe6-FPU,19539
26
+ plotnine/_mpl/layout_manager/_spaces.py,sha256=k4Pm6rJZUvOEgCAsKiSPszIsvEkARyU5vWBX38s_KkA,32389
27
27
  plotnine/_utils/__init__.py,sha256=9RVYfwi9NmDGGzprUlE40yv9tJhW_VDAZkUKTTuvN7g,32473
28
28
  plotnine/_utils/context.py,sha256=HPQy_uyNXdS0s9URD7ZePyuc5hFU2XrRBLDTqRDLJzY,1708
29
29
  plotnine/_utils/dev.py,sha256=0qgRbMhcd4dfuLuYxx0skocKAtfwHF02ntyILRBogbg,1629
@@ -69,11 +69,11 @@ plotnine/geoms/annotation_stripes.py,sha256=6LpfWzyAS6xpzTfIPorhZaVBL9j6jiSIoyVW
69
69
  plotnine/geoms/geom.py,sha256=kn3ekM968Xx3nmRRQxI1sGcEl44LBv8E3m4nUC4njMI,16549
70
70
  plotnine/geoms/geom_abline.py,sha256=Q9FNVIQMLr5Xq-fsGD8H17D6f8uOdOhGoDoMI-GTqU4,3178
71
71
  plotnine/geoms/geom_area.py,sha256=wvQ4nNvhJNN3nfn6Bv1gCARC6IWTjOjOfHPfSmg6Sxc,818
72
- plotnine/geoms/geom_bar.py,sha256=-N25T6diAE_kwRkmTXxF1NvgflGwviqvWpFLoFiclkI,1358
72
+ plotnine/geoms/geom_bar.py,sha256=SnqS4hPTfqXzdPh1U-kNuBg0LNX9_tQC9OKhIlB7cy0,1732
73
73
  plotnine/geoms/geom_bin_2d.py,sha256=b2fAQVywug-ey3KtqeOoYQ2RNRSRE_fa4s7M41W-_FE,574
74
74
  plotnine/geoms/geom_blank.py,sha256=au-WTJRdOcSfq_qQ6TugrSzjAweZfTElH9l8yYrmZ0I,821
75
75
  plotnine/geoms/geom_boxplot.py,sha256=m9vrF4i4Cw_NGxZ9WLVPsDxbqQ3jJFtnJ-UrtE3teCI,9775
76
- plotnine/geoms/geom_col.py,sha256=UTdMmpGIjVNyB7BIH9b90LiS8iuf9sChCG20EgWY3Ao,870
76
+ plotnine/geoms/geom_col.py,sha256=sGZZzkXgwlgI2D-M1UE1QAWsWZqtP1z8R0HjrnW9hkY,1187
77
77
  plotnine/geoms/geom_count.py,sha256=IRIlQqwt0kIHf9swYZbFqd5RSQCYRyFKm5hp1C9xHB4,511
78
78
  plotnine/geoms/geom_crossbar.py,sha256=-TN5UohaMNYPjYavbvW9feyW_fR-CBxFqyvSSrPIYZQ,6237
79
79
  plotnine/geoms/geom_density.py,sha256=UwUkJxI79L3z19tmoSI6ZYs418XTbRznd-Abzrec3VY,526
@@ -108,7 +108,7 @@ plotnine/geoms/geom_spoke.py,sha256=s-kug0H-YGhyjso9W43XvzJf9-g6inh8zzuSFeXzSaU,
108
108
  plotnine/geoms/geom_step.py,sha256=oKi2lWG7M2lGpF4G0yC7_5qn5tb-gc3o4H-pu3_wS64,2364
109
109
  plotnine/geoms/geom_text.py,sha256=CqmjsOJsAsIsTm6SkVJt0gms363AXpLN_JCTpuK6Krw,11908
110
110
  plotnine/geoms/geom_tile.py,sha256=3x9BSxaSr-ys6N5R2wY8B9fNiyV9vMdoXbjIDqHy_ng,1431
111
- plotnine/geoms/geom_violin.py,sha256=-lXdHQjrPAQXvFEviGi9iJqU_8L6_YrYYXwg62-8N58,6545
111
+ plotnine/geoms/geom_violin.py,sha256=VqwD2GiCRUCq5tLQbQVcLiXSaxWO-OMe9_K9GnWqdrs,7147
112
112
  plotnine/geoms/geom_vline.py,sha256=qKUd4IosH1VrwHbqNbs29kZyIW5lQRa_LZLRcLbPg38,3377
113
113
  plotnine/guides/__init__.py,sha256=ulI-mDhtq3jAQEAqPv8clrn3UHGFOu3xRuO7jXlq-LY,201
114
114
  plotnine/guides/guide.py,sha256=L1O26atzSjiQR-YW9w3XEcH0BDFiqthdY6wA6DAOapo,8229
@@ -118,8 +118,9 @@ plotnine/guides/guide_legend.py,sha256=0CSyRxlHu0RSJjnMWvnStnq-EtjgdmHv54QgPWC3u
118
118
  plotnine/guides/guides.py,sha256=UI3AhTOQCsXur_L-Jr3VwAQcX4dBeZMohSbA2wg1YeA,15478
119
119
  plotnine/mapping/__init__.py,sha256=DLu9E0kwwuHxzTUenoVjCNTTdkWMwIDtkExLleBq1MI,205
120
120
  plotnine/mapping/_env.py,sha256=ZzcSv54PLOD8b8Ny2h6xteGoO8bJdbj9dM6Mlg5h0V8,6094
121
- plotnine/mapping/aes.py,sha256=feWP04esVXjrpHlIsSRgoGvFtJjQ4noy-2gIp5MdUww,16192
122
- plotnine/mapping/evaluation.py,sha256=91xVP2KxCM-ur_KOFyki7Jm1t8GWPA1TecJbjZ9yOaQ,7699
121
+ plotnine/mapping/_eval_environment.py,sha256=ZPzIh61maJl79VQMj6ZsBGcaVV870SIARqI-Z4eNeXA,2463
122
+ plotnine/mapping/aes.py,sha256=fMwZJ7MhAcUqhUIb3ekmI_RVpKotJxCl66ale4P3B10,15797
123
+ plotnine/mapping/evaluation.py,sha256=kblTxVv3M4xIGnHyReUU0RtmmIN77Or2JBcci0nGGfE,5913
123
124
  plotnine/plot_composition/__init__.py,sha256=ZJYpfVF158cQZ1zREXy6wHNJ4FbSmqWxIkHWZwX3QT8,148
124
125
  plotnine/plot_composition/_compose.py,sha256=uQfXxDbEayqyjX6yMOCJiePqAK7I2OU5YnBhphkYFY4,11477
125
126
  plotnine/plot_composition/_plotspec.py,sha256=0F7q7PjDMDqcallpnBdX3N2iSRjdBTyjSvMFf83uvPU,1015
@@ -175,7 +176,7 @@ plotnine/stats/stat_pointdensity.py,sha256=Lnf6vsqVEs9FxgnB-zk_LnzLfMBvH8MV4KQWN
175
176
  plotnine/stats/stat_qq.py,sha256=aDjfUHqyBFFErBAJ5E9LJZU8SVBJbCnXSUl2-AJ3DQg,2876
176
177
  plotnine/stats/stat_qq_line.py,sha256=_J6XvNXQwChhwl4dDgKl1czLnZhm04jXp9xwylsIwsU,3305
177
178
  plotnine/stats/stat_quantile.py,sha256=D-TC-c2Ul0eFggz688X0l6MaqDrVmCBMM3aviiLTpHc,2481
178
- plotnine/stats/stat_sina.py,sha256=k9q34TMvkLEv3mRvWIefoZHR1EogmYEydDiH5fhxwNI,8343
179
+ plotnine/stats/stat_sina.py,sha256=IAUxNoj001jctsSQaRiN81GpGFTeX35dqdWLMvnzIaA,9449
179
180
  plotnine/stats/stat_smooth.py,sha256=hiJ5y7ai_qaFv8xxPeLs3BS6IPRvOy6Nor6SGA2H0wU,8016
180
181
  plotnine/stats/stat_sum.py,sha256=CgxZlcwJ8t8gVojwSz55UEOKwDC0MfI5ISWrwDbqy4w,1655
181
182
  plotnine/stats/stat_summary.py,sha256=RuuliRixo9c6sDnKmsQBXSJvfzkH2Oru7tyl88mpqzI,9289
@@ -199,7 +200,7 @@ plotnine/themes/theme_seaborn.py,sha256=l4lz5qD5CqhqBDgfQ61Mye3TX3f5aCHoHHx1Q04j
199
200
  plotnine/themes/theme_tufte.py,sha256=qUOrZhQyfJgc0fmy8Es7tT7aYqUSzCjvkP7-dBilwHE,1926
200
201
  plotnine/themes/theme_void.py,sha256=bU2REV9dI4x2kDIlPO6Oaq3E5di2cE8VDH-hRrnWEMc,3367
201
202
  plotnine/themes/theme_xkcd.py,sha256=q3i1W97kBwpCRbR_Y609JxcfJA2cEX5e5iAS7flbF6I,2257
202
- plotnine/themes/themeable.py,sha256=nWgJ-0u4TnXH2rJa-qScUT-Hrefc2O7lPfYnmTTnttg,69179
203
+ plotnine/themes/themeable.py,sha256=kYrk9Bq4274BD5cz1fGRgkdDwrvKhznrbhViXxl0PG0,69181
203
204
  plotnine/themes/elements/__init__.py,sha256=Z9xHdhyWPNR2uF_P80aBEXYWp1DU-T2KGOuM7VimpbM,295
204
205
  plotnine/themes/elements/element_base.py,sha256=D7cfEglzsSuhW91KpZVAZ2MAHWZp64r9Aajoh8uMGZ4,832
205
206
  plotnine/themes/elements/element_blank.py,sha256=4r7-6HeR1494oWNIGQh0ASrFQ4SLvYa6aQHA85eH-Ds,187
@@ -207,8 +208,8 @@ plotnine/themes/elements/element_line.py,sha256=xF6xW-iA66YEP_fN7ooqaYry8_8qZT-e
207
208
  plotnine/themes/elements/element_rect.py,sha256=w5cLH-Sr4cTRXVdkRiu8kBqFt3TXHhIb1MUITfi89gE,1767
208
209
  plotnine/themes/elements/element_text.py,sha256=8yhwBa9s9JKCtBcqcBNybbCGK6ieDnZv4SHiC4Sy2qc,6255
209
210
  plotnine/themes/elements/margin.py,sha256=EsT46lqky7APHxMUDiNiTieNo_SIbHF-Sjhmf9zo4WY,2880
210
- plotnine-0.15.0.dev2.dist-info/licenses/LICENSE,sha256=GY4tQiUd17Tq3wWR42Zs9MRTFOTf6ahIXhZTcwAdOeU,1082
211
- plotnine-0.15.0.dev2.dist-info/METADATA,sha256=FYs_q0rKbRXD28ywez0hpKxIKULY21v9RCMw2dEgClI,9289
212
- plotnine-0.15.0.dev2.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
213
- plotnine-0.15.0.dev2.dist-info/top_level.txt,sha256=t340Mbko1ZbmvYPkQ81dIiPHcaQdTUszYz-bWUpr8ys,9
214
- plotnine-0.15.0.dev2.dist-info/RECORD,,
211
+ plotnine-0.15.0.dev3.dist-info/licenses/LICENSE,sha256=GY4tQiUd17Tq3wWR42Zs9MRTFOTf6ahIXhZTcwAdOeU,1082
212
+ plotnine-0.15.0.dev3.dist-info/METADATA,sha256=0Cwjkbtul-pvjlRpBQzRcLgAbr4G9rI3NLSu6sLhtgA,9289
213
+ plotnine-0.15.0.dev3.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
214
+ plotnine-0.15.0.dev3.dist-info/top_level.txt,sha256=t340Mbko1ZbmvYPkQ81dIiPHcaQdTUszYz-bWUpr8ys,9
215
+ plotnine-0.15.0.dev3.dist-info/RECORD,,