ggplot2-python 4.0.2.9000.post3__py3-none-any.whl → 4.0.2.9000.post4__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.
ggplot2_py/__init__.py CHANGED
@@ -7,7 +7,7 @@ approach to creating statistical visualizations.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- __version__ = "4.0.2.9000.post3"
10
+ __version__ = "4.0.2.9000.post4"
11
11
  __r_commit__ = "c02c05a"
12
12
 
13
13
  # ---------------------------------------------------------------------------
@@ -962,6 +962,11 @@ __all__ = [
962
962
  "derive", "flip_data", "flipped_names", "has_flipped_aes",
963
963
  # Plugin discovery
964
964
  "discover_extensions", "list_extensions",
965
+ # Python-exclusive extension surface (README quickstart uses
966
+ # ``from ggplot2_py import *``; these would otherwise be invisible)
967
+ "ggplot_defaults", "BuildStage",
968
+ "GeomProtocol", "StatProtocol", "ScaleProtocol",
969
+ "CoordProtocol", "FacetProtocol", "PositionProtocol",
965
970
  ]
966
971
 
967
972
  # ---------------------------------------------------------------------------
ggplot2_py/plot.py CHANGED
@@ -17,12 +17,14 @@ from __future__ import annotations
17
17
  import contextlib
18
18
  import contextvars
19
19
  import copy
20
+ import inspect
20
21
  import warnings
21
22
  from functools import singledispatch
22
23
  from typing import (
23
24
  Any,
24
25
  Callable,
25
26
  Dict,
27
+ Iterable,
26
28
  List,
27
29
  Optional,
28
30
  Sequence,
@@ -142,6 +144,14 @@ def ggplot_defaults(
142
144
  that apply to all :func:`ggplot` calls within the ``with`` block,
143
145
  without affecting code outside.
144
146
 
147
+ The context defaults are applied at the **end** of the :func:`ggplot`
148
+ factory, after the plot's intrinsic defaults
149
+ (``CoordCartesian(default=True)``, ``FacetNull()``, empty :class:`Theme`)
150
+ are set. The context-provided ``coord`` is marked ``default=True`` so a
151
+ subsequent ``plot + coord_X()`` replaces it silently (matching R's
152
+ ``update_ggplot.Coord`` semantics on default coords —
153
+ ``plot-construction.R:200-215``).
154
+
145
155
  Parameters
146
156
  ----------
147
157
  theme : Theme or dict, optional
@@ -184,6 +194,42 @@ def _get_context_defaults() -> Dict[str, Any]:
184
194
  return _ggplot_context.get()
185
195
 
186
196
 
197
+ def _apply_context_defaults(p: "GGPlot") -> None:
198
+ """Overlay scoped defaults from :func:`ggplot_defaults` onto *p*.
199
+
200
+ Called from the :func:`ggplot` factory **after** the intrinsic defaults
201
+ (``CoordCartesian(default=True)``, ``FacetNull()``, empty :class:`Theme`)
202
+ have been installed, so context values can override them cleanly.
203
+
204
+ Each ctx value is shallow-copied before assignment so callers can reuse
205
+ the same ``coord_fixed()`` / ``facet_wrap(...)`` instance across multiple
206
+ ``ggplot()`` calls without us mutating their object.
207
+
208
+ For ``coord``, ``default = True`` is preserved on the copy so that a
209
+ later ``+ coord_X()`` replaces it silently (matches R's
210
+ ``update_ggplot.Coord`` short-circuit on default coords —
211
+ ``plot-construction.R:202``).
212
+ """
213
+ ctx = _get_context_defaults()
214
+ if not ctx:
215
+ return
216
+
217
+ if "theme" in ctx:
218
+ new_theme = ctx["theme"]
219
+ p.theme = new_theme.copy() if hasattr(new_theme, "copy") else new_theme
220
+
221
+ if "coord" in ctx:
222
+ p.coordinates = copy.copy(ctx["coord"])
223
+ p.coordinates.default = True
224
+
225
+ if "facet" in ctx:
226
+ p.facet = copy.copy(ctx["facet"])
227
+
228
+ if "mapping" in ctx:
229
+ ctx_map = ctx["mapping"]
230
+ p.mapping = aes(**{**ctx_map, **p.mapping})
231
+
232
+
187
233
  # ---------------------------------------------------------------------------
188
234
  # GGPlot class
189
235
  # ---------------------------------------------------------------------------
@@ -249,19 +295,11 @@ class GGPlot:
249
295
  self._meta: Dict[str, Any] = {}
250
296
  self._build_hooks: Dict[Tuple[str, str], List[Callable]] = {}
251
297
 
252
- # Apply scoped context defaults (Python-exclusive feature).
253
- ctx = _get_context_defaults()
254
- if ctx:
255
- if "theme" in ctx and not self.theme:
256
- self.theme = ctx["theme"]
257
- if "coord" in ctx and self.coordinates is None:
258
- self.coordinates = ctx["coord"]
259
- if "facet" in ctx and self.facet is None:
260
- self.facet = ctx["facet"]
261
- if "mapping" in ctx:
262
- # Merge: context defaults as base, explicit mapping overrides
263
- merged = aes(**{**ctx["mapping"], **self.mapping})
264
- self.mapping = merged
298
+ # NOTE: scoped-default application (``ggplot_defaults``) is intentionally
299
+ # NOT done here. It happens in the :func:`ggplot` factory via
300
+ # :func:`_apply_context_defaults` so that direct ``GGPlot(...)`` calls
301
+ # and ``_clone()`` (which uses ``copy.copy`` and skips ``__init__``) are
302
+ # unaffected by global context.
265
303
 
266
304
  # ------------------------------------------------------------------
267
305
  # Clone
@@ -303,9 +341,14 @@ class GGPlot:
303
341
  A :class:`BuildStage` constant (e.g.
304
342
  ``BuildStage.COMPUTE_STAT``).
305
343
  fn : callable
306
- ``fn(data, **ctx) -> data_or_None``. Receives the current
307
- per-layer data list. Return a new list to replace it, or
308
- ``None`` to leave it unchanged.
344
+ ``fn(data, **ctx) -> list_or_anything``. Receives the current
345
+ per-layer data list. Return a new ``list`` to replace it; any
346
+ non-list return (including ``None``) leaves the data unchanged.
347
+ ``**ctx`` carries stage-specific context (``layout``, ``scales``,
348
+ ``guides``, ``theme``) — see :class:`BuildStage` for the per-stage
349
+ table. Hook signatures are introspected, so you may declare
350
+ only the kwargs you want (``def fn(data, layout=None)``) or use
351
+ ``**kw`` to receive everything.
309
352
 
310
353
  Returns
311
354
  -------
@@ -535,6 +578,11 @@ def ggplot(
535
578
  p.coordinates.default = True
536
579
  p.facet = FacetNull()
537
580
 
581
+ # Scoped defaults (``ggplot_defaults`` context manager) overlay the
582
+ # intrinsic defaults set above. No-op when no context is active, which
583
+ # preserves byte-level R parity for the bare ``ggplot(df, aes(...))`` case.
584
+ _apply_context_defaults(p)
585
+
538
586
  # R parity: ``plot$labels`` stores ONLY user-set labels. The
539
587
  # aesthetic-derived defaults (``x="carat"``, ``y="price"`` etc.)
540
588
  # are computed lazily at render time by ``_setup_plot_labels``
@@ -596,14 +644,45 @@ class BuildStage:
596
644
  These are used with :meth:`GGPlot.add_build_hook` to register
597
645
  before/after callbacks on specific pipeline stages. This is a
598
646
  **Python-exclusive** extension point — R's ggplot2 does not expose
599
- hooks on individual build stages.
600
-
601
- Example
602
- -------
603
- ::
604
-
605
- p = ggplot(df, aes("x", "y"))
606
- p.add_build_hook("after", BuildStage.COMPUTE_STAT, my_callback)
647
+ hooks on individual build stages — but **every stage name here
648
+ corresponds to a real operation in R's** ``plot-build.R``
649
+ ``ggplot_build.ggplot()`` method, in the same order.
650
+
651
+ Per-stage available ctx kwargs
652
+ ------------------------------
653
+ The hook receives ``(data, **ctx)``. ``data`` is always the current
654
+ per-layer data list. ``**ctx`` carries side context whose set depends
655
+ on the stage:
656
+
657
+ ===================== =================================================
658
+ Stage ctx keys
659
+ ===================== =================================================
660
+ LAYER_DATA (none)
661
+ SETUP_LAYER (none)
662
+ SETUP_LAYOUT ``layout``
663
+ COMPUTE_AESTHETICS (none)
664
+ TRANSFORM_SCALES ``scales``
665
+ TRAIN_POSITION ``layout``, ``scales``
666
+ COMPUTE_STAT ``layout``
667
+ MAP_STAT (none)
668
+ COMPUTE_GEOM_1 (none)
669
+ COMPUTE_POSITION ``layout``
670
+ RETRAIN_POSITION ``layout``, ``scales``
671
+ SETUP_GUIDES ``layout``, ``guides``
672
+ TRAIN_NONPOSITION ``scales`` (the non-position ScalesList)
673
+ COMPUTE_GEOM_2 ``theme``
674
+ FINISH_STAT (none)
675
+ FINISH_DATA (none)
676
+ ===================== =================================================
677
+
678
+ Hook signature flexibility — :func:`_run_hooks` introspects each hook
679
+ and forwards only ctx kwargs the hook can accept, so all of these
680
+ coexist::
681
+
682
+ p.add_build_hook("after", BuildStage.TRAIN_POSITION, lambda data: ...)
683
+ p.add_build_hook("after", BuildStage.TRAIN_POSITION, lambda data, **kw: ...)
684
+ p.add_build_hook("after", BuildStage.TRAIN_POSITION,
685
+ lambda data, layout=None, scales=None: ...)
607
686
  """
608
687
 
609
688
  LAYER_DATA = "layer_data"
@@ -624,6 +703,26 @@ class BuildStage:
624
703
  FINISH_DATA = "finish_data"
625
704
 
626
705
 
706
+ def _hook_accepts(hook: Callable, ctx_keys: Iterable[str]) -> Dict[str, bool]:
707
+ """Return a ``{ctx_key: accepted}`` map for *hook*.
708
+
709
+ A key is accepted if the hook either declares it as a named parameter or
710
+ declares a ``**kwargs`` catch-all. Used by :func:`_run_hooks` to forward
711
+ only the ctx kwargs the hook actually wants — this is what lets
712
+ ``lambda data: ...`` (old-style) and ``lambda data, **ctx: ...`` and
713
+ ``def f(data, layout=None): ...`` all coexist without TypeErrors and
714
+ without resorting to ``try/except``.
715
+ """
716
+ sig = inspect.signature(hook)
717
+ params = sig.parameters
718
+ has_var_keyword = any(
719
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
720
+ )
721
+ if has_var_keyword:
722
+ return {k: True for k in ctx_keys}
723
+ return {k: (k in params) for k in ctx_keys}
724
+
725
+
627
726
  def _run_hooks(
628
727
  plot: GGPlot,
629
728
  timing: str,
@@ -633,6 +732,21 @@ def _run_hooks(
633
732
  ) -> List[Any]:
634
733
  """Execute registered build hooks for (*timing*, *stage*).
635
734
 
735
+ Each hook is invoked as ``hook(data, **selected_ctx)`` where
736
+ ``selected_ctx`` is the subset of *ctx* that the hook actually accepts
737
+ (named param or ``**kwargs``). This keeps three call styles working
738
+ in parallel:
739
+
740
+ * ``lambda data: ...`` — no ctx forwarded
741
+ * ``lambda data, **kw: ...`` — every ctx kwarg forwarded
742
+ * ``def f(data, layout=None, scales=None)`` — only matching kwargs
743
+
744
+ Hooks may return a new per-layer data list (an actual ``list``) to
745
+ replace the pipeline data, or any non-list value (including ``None``)
746
+ to leave it unchanged. The list-only test means hooks that accidentally
747
+ return a scalar (``True``, the value from ``log.setdefault``, etc.)
748
+ don't silently corrupt downstream stages.
749
+
636
750
  Parameters
637
751
  ----------
638
752
  plot : GGPlot
@@ -644,7 +758,8 @@ def _run_hooks(
644
758
  data : list
645
759
  Current per-layer data list.
646
760
  **ctx
647
- Additional context (e.g. ``layout``, ``scales``) passed to hooks.
761
+ Additional context. The set of keys available depends on the
762
+ stage — see :class:`BuildStage` for the per-stage table.
648
763
 
649
764
  Returns
650
765
  -------
@@ -654,9 +769,14 @@ def _run_hooks(
654
769
  hooks = getattr(plot, "_build_hooks", None)
655
770
  if not hooks:
656
771
  return data
657
- for hook in hooks.get((timing, stage), []):
658
- result = hook(data, **ctx)
659
- if result is not None:
772
+ targets = hooks.get((timing, stage), [])
773
+ if not targets:
774
+ return data
775
+ for hook in targets:
776
+ wanted = _hook_accepts(hook, ctx.keys())
777
+ kwargs = {k: v for k, v in ctx.items() if wanted.get(k, False)}
778
+ result = hook(data, **kwargs)
779
+ if isinstance(result, list):
660
780
  data = result
661
781
  return data
662
782
 
@@ -776,9 +896,11 @@ def _build_ggplot(plot):
776
896
  data = by_layer(lambda l, d: l.setup_layer(d, plot), layers, data, "setting up layer")
777
897
  data = _h(plot, "after", S.SETUP_LAYER, data)
778
898
 
779
- # --- Setup layout ---
899
+ # --- Setup layout --- (R: plot-build.R:62 ``layout$setup``)
780
900
  layout = create_layout(plot.facet, plot.coordinates, getattr(plot, "layout", None))
901
+ data = _h(plot, "before", S.SETUP_LAYOUT, data, layout=layout)
781
902
  data = layout.setup(data, plot.data if isinstance(plot.data, pd.DataFrame) else pd.DataFrame(), plot.plot_env)
903
+ data = _h(plot, "after", S.SETUP_LAYOUT, data, layout=layout)
782
904
 
783
905
  # --- Compute aesthetics ---
784
906
  data = _h(plot, "before", S.COMPUTE_AESTHETICS, data)
@@ -793,21 +915,25 @@ def _build_ggplot(plot):
793
915
  # --- Setup plot labels ---
794
916
  _setup_plot_labels(plot, layers, data)
795
917
 
796
- # --- Transform scales ---
918
+ # --- Transform scales --- (R: plot-build.R:70 ``lapply(data, scales$transform_df)``)
919
+ data = _h(plot, "before", S.TRANSFORM_SCALES, data, scales=scales)
797
920
  for i in range(len(data)):
798
921
  if data[i] is not None and not data[i].empty:
799
922
  data[i] = scales.transform_df(data[i])
923
+ data = _h(plot, "after", S.TRANSFORM_SCALES, data, scales=scales)
800
924
 
801
- # --- Train and map positions ---
925
+ # --- Train and map positions --- (R: plot-build.R:77-78)
802
926
  scale_x = scales.get_scales("x")
803
927
  scale_y = scales.get_scales("y")
928
+ data = _h(plot, "before", S.TRAIN_POSITION, data, layout=layout, scales=scales)
804
929
  layout.train_position(data, scale_x, scale_y)
805
930
  data = layout.map_position(data)
931
+ data = _h(plot, "after", S.TRAIN_POSITION, data, layout=layout, scales=scales)
806
932
 
807
933
  # --- Compute statistics ---
808
- data = _h(plot, "before", S.COMPUTE_STAT, data)
934
+ data = _h(plot, "before", S.COMPUTE_STAT, data, layout=layout)
809
935
  data = by_layer(lambda l, d: l.compute_statistic(d, layout), layers, data, "computing stat")
810
- data = _h(plot, "after", S.COMPUTE_STAT, data)
936
+ data = _h(plot, "after", S.COMPUTE_STAT, data, layout=layout)
811
937
 
812
938
  # --- Map statistics ---
813
939
  data = _h(plot, "before", S.MAP_STAT, data)
@@ -834,20 +960,24 @@ def _build_ggplot(plot):
834
960
  data = _h(plot, "after", S.COMPUTE_GEOM_1, data)
835
961
 
836
962
  # --- Compute position ---
837
- data = _h(plot, "before", S.COMPUTE_POSITION, data)
963
+ data = _h(plot, "before", S.COMPUTE_POSITION, data, layout=layout)
838
964
  data = by_layer(lambda l, d: l.compute_position(d, layout), layers, data, "computing position")
839
- data = _h(plot, "after", S.COMPUTE_POSITION, data)
965
+ data = _h(plot, "after", S.COMPUTE_POSITION, data, layout=layout)
840
966
 
841
- # --- Reset and retrain position scales ---
967
+ # --- Reset and retrain position scales --- (R: plot-build.R:99-101)
842
968
  scale_x = scales.get_scales("x")
843
969
  scale_y = scales.get_scales("y")
970
+ data = _h(plot, "before", S.RETRAIN_POSITION, data, layout=layout, scales=scales)
844
971
  layout.reset_scales()
845
972
  layout.train_position(data, scale_x, scale_y)
846
973
  layout.setup_panel_params()
847
974
  data = layout.map_position(data)
975
+ data = _h(plot, "after", S.RETRAIN_POSITION, data, layout=layout, scales=scales)
848
976
 
849
- # --- Setup panel guides ---
977
+ # --- Setup panel guides --- (R: plot-build.R:104 ``layout$setup_panel_guides``)
978
+ data = _h(plot, "before", S.SETUP_GUIDES, data, layout=layout, guides=plot.guides)
850
979
  layout.setup_panel_guides(plot.guides, plot.layers)
980
+ data = _h(plot, "after", S.SETUP_GUIDES, data, layout=layout, guides=plot.guides)
851
981
 
852
982
  # --- Complete theme ---
853
983
  # R: plot@theme <- plot_theme(plot) (plot-build.R:107)
@@ -856,14 +986,16 @@ def _build_ggplot(plot):
856
986
  # ``calc_element`` then returns ``None`` (no bg, no grid, no titles).
857
987
  plot.theme = complete_theme(plot.theme)
858
988
 
859
- # --- Train non-position scales and guides ---
989
+ # --- Train non-position scales and guides --- (R: plot-build.R:113 ``lapply(data, npscales$train_df)``)
860
990
  npscales = scales.non_position_scales()
861
991
  if npscales.n() > 0:
862
992
  if hasattr(npscales, "set_palettes"):
863
993
  npscales.set_palettes(plot.theme)
994
+ data = _h(plot, "before", S.TRAIN_NONPOSITION, data, scales=npscales)
864
995
  for d in data:
865
996
  if d is not None:
866
997
  npscales.train_df(d)
998
+ data = _h(plot, "after", S.TRAIN_NONPOSITION, data, scales=npscales)
867
999
  if plot.guides is not None and hasattr(plot.guides, "build"):
868
1000
  plot.guides = plot.guides.build(npscales, plot.layers, plot.labels, data, plot.theme)
869
1001
  for i in range(len(data)):
@@ -874,9 +1006,9 @@ def _build_ggplot(plot):
874
1006
  plot.guides = plot.guides.get_custom()
875
1007
 
876
1008
  # --- Compute geom 2 ---
877
- data = _h(plot, "before", S.COMPUTE_GEOM_2, data)
1009
+ data = _h(plot, "before", S.COMPUTE_GEOM_2, data, theme=plot.theme)
878
1010
  data = by_layer(lambda l, d: l.compute_geom_2(d, theme=plot.theme), layers, data, "setting up geom aesthetics")
879
- data = _h(plot, "after", S.COMPUTE_GEOM_2, data)
1011
+ data = _h(plot, "after", S.COMPUTE_GEOM_2, data, theme=plot.theme)
880
1012
 
881
1013
  # --- Finish statistics ---
882
1014
  data = _h(plot, "before", S.FINISH_STAT, data)
ggplot2_py/protocols.py CHANGED
@@ -1,33 +1,54 @@
1
1
  """
2
2
  Structural typing protocols for ggplot2_py GOG components.
3
3
 
4
- These :class:`~typing.Protocol` definitions specify the **contracts** that
5
- custom Geom, Stat, Scale, Coord, Facet, and Position classes should satisfy.
6
- They are ``@runtime_checkable``, so you can use ``isinstance()`` to verify
7
- compliance without requiring inheritance from the base classes.
8
-
9
- This is a **Python-exclusive** feature — R's ggplot2 has no equivalent
10
- compile-time or runtime contract checking.
4
+ These :class:`~typing.Protocol` definitions are the **machine-readable
5
+ contract** that every Geom, Stat, Scale, Coord, Facet, and Position class
6
+ shipped or user-supplied must satisfy.
7
+
8
+ What this module is **not**
9
+ ---------------------------
10
+ * It is not a replacement for the base classes (``Geom``, ``Stat``, etc.).
11
+ Internal code dispatches off the base classes; the Protocols are an
12
+ orthogonal, structural view.
13
+ * It does **not** make the base classes interchangeable with arbitrary
14
+ duck-typed objects in the build pipeline — the singledispatch + auto-
15
+ registration machinery still keys on the concrete base classes.
16
+
17
+ What this module **is**
18
+ -----------------------
19
+ 1. **Static-typing aid.** Extension authors writing ``MyStat(Stat)`` get
20
+ mypy / pyright errors when they omit ``compute_group``, return the
21
+ wrong type, etc. The signatures here mirror the base classes
22
+ verbatim — R's ``ggproto`` field signatures, transitively.
23
+ 2. **Live contract test.** ``tests/test_protocols_contract.py`` iterates
24
+ every shipped subclass and asserts ``isinstance(instance, XxxProtocol)``.
25
+ Signature drift in a base class — or accidental method removal in a
26
+ subclass — fails CI immediately. Without that test these Protocols
27
+ would just be documentation; with it they are an executable spec.
28
+
29
+ R parity
30
+ --------
31
+ R has no Protocol mechanism (``ggproto`` is structural duck-typing all the
32
+ way down), so the Protocols themselves are Python-exclusive. However each
33
+ method signature here is **derived from the corresponding R ``ggproto``
34
+ field** by going through the Python base class (``Geom`` / ``Stat`` / …),
35
+ which was ported from the R prototype. So the contract is transitively
36
+ R-aligned.
11
37
 
12
38
  Usage
13
39
  -----
14
- Mypy / pyright will flag violations statically::
40
+ Static (mypy / pyright)::
15
41
 
16
42
  class MyStat(Stat):
17
43
  required_aes = ("x",)
18
- # Missing compute_group type error
44
+ # Forgetting compute_group is a static type error.
19
45
 
20
- At runtime you can check::
46
+ Runtime (rare; the contract test in ``tests/`` already covers shipped
47
+ classes — extension authors needing dynamic checks can do)::
21
48
 
22
49
  from ggplot2_py.protocols import StatProtocol
23
- assert isinstance(my_stat, StatProtocol)
24
-
25
- Notes
26
- -----
27
- These protocols describe the *minimum* interface required for each
28
- component to participate in the GOG pipeline. They do **not** replace
29
- the base classes (``Geom``, ``Stat``, etc.) — they complement them by
30
- enabling structural (duck-typed) checking.
50
+ if not isinstance(my_stat, StatProtocol):
51
+ raise TypeError("Stat extension is missing required methods")
31
52
  """
32
53
 
33
54
  from __future__ import annotations
@@ -36,6 +57,7 @@ from typing import (
36
57
  Any,
37
58
  Dict,
38
59
  List,
60
+ Optional,
39
61
  Protocol,
40
62
  Sequence,
41
63
  Tuple,
@@ -56,116 +78,125 @@ __all__ = [
56
78
 
57
79
 
58
80
  # ---------------------------------------------------------------------------
59
- # Geom
81
+ # Geom — signatures from ``ggplot2_py.geom.Geom``
60
82
  # ---------------------------------------------------------------------------
61
83
 
62
84
  @runtime_checkable
63
85
  class GeomProtocol(Protocol):
64
- """Contract for geometry objects.
65
-
66
- A conforming Geom must declare its aesthetic requirements and provide
67
- at least ``draw_panel`` or ``draw_group`` for rendering.
68
- """
86
+ """Contract for geometry objects. Mirrors ``Geom`` (geom.py:462)."""
69
87
 
70
88
  required_aes: Union[Tuple[str, ...], List[str]]
71
- default_aes: Any # Mapping or dict
72
- draw_key: Any # callable
89
+ default_aes: Any # Mapping or dict
90
+ draw_key: Any # callable (class-level attribute)
73
91
 
74
- def setup_params(self, data: pd.DataFrame, params: dict) -> dict: ...
75
- def setup_data(self, data: pd.DataFrame, params: dict) -> pd.DataFrame: ...
76
- def draw_panel(self, data: pd.DataFrame, panel_params: dict,
77
- coord: Any, **kwargs: Any) -> Any: ...
92
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]: ...
93
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame: ...
94
+ def draw_panel(self, *args: Any, **kwargs: Any) -> Any: ...
78
95
 
79
96
 
80
97
  # ---------------------------------------------------------------------------
81
- # Stat
98
+ # Stat — signatures from ``ggplot2_py.stat.Stat``
82
99
  # ---------------------------------------------------------------------------
83
100
 
84
101
  @runtime_checkable
85
102
  class StatProtocol(Protocol):
86
- """Contract for statistical transformation objects.
103
+ """Contract for statistical transformation objects. Mirrors ``Stat``
104
+ (stat.py base class).
87
105
 
88
- A conforming Stat must declare its aesthetic requirements and provide
89
- ``compute_group`` (or ``compute_panel`` / ``compute_layer``).
106
+ The Protocol pins the three methods most likely to be user-overridden;
107
+ a Stat may also override ``compute_layer`` or ``compute_panel`` instead
108
+ of (or in addition to) ``compute_group``.
90
109
  """
91
110
 
92
111
  required_aes: Union[Tuple[str, ...], List[str]]
93
112
  default_aes: Any
94
113
 
95
- def setup_params(self, data: pd.DataFrame, params: dict) -> dict: ...
96
- def setup_data(self, data: pd.DataFrame, params: dict) -> pd.DataFrame: ...
97
- def compute_group(self, data: pd.DataFrame, scales: Any,
98
- **params: Any) -> pd.DataFrame: ...
114
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]: ...
115
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame: ...
116
+ def compute_group(self, data: pd.DataFrame, scales: Any, **params: Any) -> pd.DataFrame: ...
99
117
 
100
118
 
101
119
  # ---------------------------------------------------------------------------
102
- # Scale
120
+ # Scale — signatures from ``ggplot2_py.scale.Scale``
121
+ #
122
+ # The base class accepts optional ``limits`` on ``map`` / ``get_breaks`` /
123
+ # ``get_labels``. The Protocol matches those signatures verbatim — narrower
124
+ # signatures (omitting the optional kwargs) caused the original drift.
103
125
  # ---------------------------------------------------------------------------
104
126
 
105
127
  @runtime_checkable
106
128
  class ScaleProtocol(Protocol):
107
- """Contract for scale objects.
108
-
109
- A conforming Scale mediates between data space and aesthetic space
110
- via train / transform / map.
111
- """
129
+ """Contract for scale objects. Mirrors ``Scale`` (scale.py:410)."""
112
130
 
113
- aesthetics: Any # list of str
131
+ aesthetics: Any # list of str
114
132
 
115
133
  def train(self, x: Any) -> None: ...
116
134
  def transform(self, x: Any) -> Any: ...
117
- def map(self, x: Any) -> Any: ...
118
- def get_breaks(self) -> Any: ...
119
- def get_labels(self, breaks: Any = None) -> Any: ...
135
+ def map(self, x: Any, limits: Optional[Any] = ...) -> Any: ...
136
+ def get_breaks(self, limits: Optional[Any] = ...) -> Any: ...
137
+ def get_labels(self, breaks: Optional[Any] = ...) -> Any: ...
120
138
  def clone(self) -> Any: ...
121
139
 
122
140
 
123
141
  # ---------------------------------------------------------------------------
124
- # Coord
142
+ # Coord — signatures from ``ggplot2_py.coord.Coord``
125
143
  # ---------------------------------------------------------------------------
126
144
 
127
145
  @runtime_checkable
128
146
  class CoordProtocol(Protocol):
129
- """Contract for coordinate system objects.
147
+ """Contract for coordinate system objects. Mirrors ``Coord``
148
+ (coord.py:494).
130
149
 
131
- A conforming Coord transforms data positions into viewport positions
132
- and renders background / axes.
150
+ ``setup_params`` takes ``Any`` (not ``list``) because the call sites
151
+ pass per-layer data lists, single DataFrames, or panel-level dicts
152
+ depending on the Coord subclass.
133
153
  """
134
154
 
135
- def setup_params(self, data: list) -> dict: ...
136
- def transform(self, data: pd.DataFrame, panel_params: dict) -> pd.DataFrame: ...
137
- def setup_panel_params(self, scale_x: Any, scale_y: Any,
138
- params: dict = ...) -> dict: ...
155
+ def setup_params(self, data: Any) -> Dict[str, Any]: ...
156
+ def transform(self, data: pd.DataFrame, panel_params: Dict[str, Any]) -> pd.DataFrame: ...
157
+ def setup_panel_params(
158
+ self,
159
+ scale_x: Any,
160
+ scale_y: Any,
161
+ params: Optional[Dict[str, Any]] = ...,
162
+ ) -> Dict[str, Any]: ...
139
163
 
140
164
 
141
165
  # ---------------------------------------------------------------------------
142
- # Facet
166
+ # Facet — signatures from ``ggplot2_py.facet.Facet``
143
167
  # ---------------------------------------------------------------------------
144
168
 
145
169
  @runtime_checkable
146
170
  class FacetProtocol(Protocol):
147
- """Contract for faceting specification objects.
148
-
149
- A conforming Facet computes panel layout and assigns data to panels.
150
- """
151
-
152
- def compute_layout(self, data: list, params: dict) -> pd.DataFrame: ...
153
- def map_data(self, data: pd.DataFrame, layout: pd.DataFrame,
154
- params: dict) -> pd.DataFrame: ...
171
+ """Contract for faceting specification objects. Mirrors ``Facet``
172
+ (facet.py:490)."""
173
+
174
+ def compute_layout(
175
+ self,
176
+ data: List[pd.DataFrame],
177
+ params: Dict[str, Any],
178
+ ) -> pd.DataFrame: ...
179
+ def map_data(
180
+ self,
181
+ data: pd.DataFrame,
182
+ layout: pd.DataFrame,
183
+ params: Dict[str, Any],
184
+ ) -> pd.DataFrame: ...
155
185
 
156
186
 
157
187
  # ---------------------------------------------------------------------------
158
- # Position
188
+ # Position — signatures from ``ggplot2_py.position.Position``
159
189
  # ---------------------------------------------------------------------------
160
190
 
161
191
  @runtime_checkable
162
192
  class PositionProtocol(Protocol):
163
- """Contract for position adjustment objects.
164
-
165
- A conforming Position adjusts data coordinates (e.g. dodge, stack)
166
- after stat computation but before coordinate transformation.
167
- """
168
-
169
- def setup_params(self, data: pd.DataFrame) -> dict: ...
170
- def compute_layer(self, data: pd.DataFrame, params: dict,
171
- layout: Any) -> pd.DataFrame: ...
193
+ """Contract for position adjustment objects. Mirrors ``Position``
194
+ (position.py:200)."""
195
+
196
+ def setup_params(self, data: pd.DataFrame) -> Dict[str, Any]: ...
197
+ def compute_layer(
198
+ self,
199
+ data: pd.DataFrame,
200
+ params: Dict[str, Any],
201
+ layout: Any,
202
+ ) -> pd.DataFrame: ...
ggplot2_py/save.py CHANGED
@@ -8,9 +8,9 @@ formats via Cairo.
8
8
  from __future__ import annotations
9
9
 
10
10
  import os
11
- from typing import Any, Dict, List, Optional, Tuple, Union
11
+ from typing import Any, Dict, Optional, Union
12
12
 
13
- from ggplot2_py._compat import cli_abort, cli_warn, cli_inform
13
+ from ggplot2_py._compat import cli_abort, cli_inform
14
14
 
15
15
  __all__ = [
16
16
  "ggsave",
@@ -204,7 +204,7 @@ def ggsave(
204
204
  If the target directory does not exist and *create_dir* is
205
205
  ``False``.
206
206
  """
207
- from grid_py import grid_draw, grid_newpage, get_state
207
+ from grid_py import grid_draw, get_state
208
208
  from grid_py.renderer import CairoRenderer
209
209
 
210
210
  # Resolve DPI
@@ -255,6 +255,11 @@ def ggsave(
255
255
  if is_ggplot(plot):
256
256
  built = ggplot_build(plot)
257
257
  gtable = ggplot_gtable(built)
258
+ elif hasattr(plot, "to_gtable"):
259
+ # patchwork-python composes plots as a Patchwork object with an
260
+ # explicit to_gtable() method. Draw that gtable rather than handing
261
+ # the wrapper to grid_draw(), which cannot render it directly.
262
+ gtable = plot.to_gtable()
258
263
  else:
259
264
  gtable = plot
260
265
 
ggplot2_py/scale.py CHANGED
@@ -1151,15 +1151,66 @@ class ScaleDiscrete(Scale):
1151
1151
  x_str = [str(v) for v in np.asarray(x)]
1152
1152
  limits_str = [str(l) for l in limits]
1153
1153
 
1154
+ na_val = self.na_value if self.na_translate else np.nan
1155
+
1156
+ # ---- Named-palette path (R: ggplot2/R/scale-.R:1322-1340) -------
1157
+ # When the palette function returned a *named* mapping (i.e. a
1158
+ # ``dict`` carrying value→aesthetic pairs), R's algorithm is:
1159
+ #
1160
+ # pal_names <- names(pal)
1161
+ # if (!is.null(pal_names)) {
1162
+ # pal[is.na(match(pal_names, limits))] <- na_value # (a)
1163
+ # pal <- vec_set_names(pal, NULL)
1164
+ # limits <- pal_names # (b)
1165
+ # }
1166
+ # pal <- c(pal, na_value) # (c)
1167
+ # pal_match <- vec_slice(
1168
+ # pal, match(as.character(x), limits, nomatch = vec_size(pal))
1169
+ # ) # (d)
1170
+ #
1171
+ # Crucially:
1172
+ # * (a) blanks pal entries whose names are *not* in the original
1173
+ # limits — so an out-of-data category looked up explicitly via
1174
+ # map(value) returns na_value rather than the user's colour;
1175
+ # * (b) reassigns ``limits`` to ``pal_names`` so the subsequent
1176
+ # match is by name (encoded as a position lookup against the
1177
+ # full named-vector key list);
1178
+ # * (c) appends na_value as a sentinel at position ``len(pal)``;
1179
+ # * (d) ``match`` returns ``len(pal)`` for unmatched x, which
1180
+ # ``vec_slice`` turns into the appended na_value.
1181
+ #
1182
+ # The Python translation below is structurally identical; only
1183
+ # surface idioms differ (dict comprehension instead of
1184
+ # ``vec_slice`` / ``match``).
1154
1185
  if isinstance(pal, dict):
1155
- pal_list = list(pal.values())
1156
- elif isinstance(pal, np.ndarray):
1186
+ pal_named = {str(k): v for k, v in pal.items()}
1187
+ limits_set = set(limits_str)
1188
+ # (a) blank entries whose name is not in the original limits.
1189
+ pal_filtered = [
1190
+ (v if k in limits_set else na_val)
1191
+ for k, v in pal_named.items()
1192
+ ]
1193
+ # (b) limits ← pal_names.
1194
+ new_limits = list(pal_named.keys())
1195
+ # (c) append na_value sentinel for unmatched positions.
1196
+ sentinel_idx = len(pal_filtered)
1197
+ pal_with_sentinel = pal_filtered + [na_val]
1198
+ # (d) positional lookup via match(x, new_limits).
1199
+ result = []
1200
+ for v in x_str:
1201
+ try:
1202
+ idx = new_limits.index(v)
1203
+ except ValueError:
1204
+ idx = sentinel_idx
1205
+ result.append(pal_with_sentinel[idx])
1206
+ return np.array(result)
1207
+
1208
+ # ---- Unnamed-palette path (R: same map(), pal_names is NULL) ----
1209
+ if isinstance(pal, np.ndarray):
1157
1210
  pal_list = list(pal)
1158
1211
  else:
1159
1212
  pal_list = list(pal) if hasattr(pal, "__iter__") else [pal]
1160
1213
 
1161
- na_val = self.na_value if self.na_translate else np.nan
1162
-
1163
1214
  result = []
1164
1215
  for v in x_str:
1165
1216
  if v in limits_str:
@@ -371,11 +371,33 @@ def _manual_scale(
371
371
  named_values[b] = values_list[i]
372
372
  values = named_values
373
373
 
374
+ # Mirror R `manual_scale` (ggplot2/R/scale-manual.R:183-188):
375
+ #
376
+ # pal <- function(n) {
377
+ # if (n > length(values)) {
378
+ # cli::cli_abort("Insufficient values in manual scale. ...")
379
+ # }
380
+ # values
381
+ # }
382
+ #
383
+ # In R `values` is a (possibly named) atomic vector and the palette
384
+ # always returns it whole — names included; the count check is a
385
+ # guard, not a slice. ``ScaleDiscrete$map`` then dispatches on
386
+ # ``names(pal)`` to decide name vs position lookup. We mirror that
387
+ # split here: dict → named palette branch (returned as-is), list →
388
+ # unnamed branch (sliced to ``n`` to match the existing call sites
389
+ # that index positionally). In both branches we keep R's
390
+ # ``n > length(values)`` abort so misuse fails the same way.
374
391
  if isinstance(values, dict):
375
- _vals = values
392
+ _vals = dict(values)
393
+ _n_vals = len(_vals)
376
394
 
377
- def pal(n: int) -> list:
378
- return list(_vals.values())[:n] if len(_vals) >= n else list(_vals.values())
395
+ def pal(n: int) -> dict:
396
+ if n > _n_vals:
397
+ cli_abort(
398
+ f"Insufficient values in manual scale. {n} needed but only {_n_vals} provided."
399
+ )
400
+ return _vals
379
401
  else:
380
402
  _vals_list = list(values)
381
403
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ggplot2-python
3
- Version: 4.0.2.9000.post3
3
+ Version: 4.0.2.9000.post4
4
4
  Summary: Python port of the R ggplot2 package (tracks R ggplot2 4.0.2.9000)
5
5
  Project-URL: Homepage, https://github.com/Bio-Babel/ggplot2-python
6
6
  Project-URL: Repository, https://github.com/Bio-Babel/ggplot2-python
@@ -48,17 +48,17 @@ Requires-Dist: mkdocs-material; extra == 'docs'
48
48
  Requires-Dist: mkdocstrings[python]; extra == 'docs'
49
49
  Description-Content-Type: text/markdown
50
50
 
51
- # ggplot2_py <a href="https://github.com/R2pyBioinformatics/ggplot2_py"><img src="assets/ggplot2_py_logo.png" align="right" height="138" alt="ggplot2_py logo" /></a>
51
+ # ggplot2-python <a href="https://github.com/Bio-Babel/ggplot2-python"><img src="assets/ggplot2_py_logo.png" align="right" height="138" alt="ggplot2-python logo" /></a>
52
52
 
53
53
  [![PyPI](https://img.shields.io/pypi/v/ggplot2-python)](https://pypi.org/project/ggplot2-python/)
54
54
 
55
- AI-assisted Python port of the R **ggplot2** package — Create Elegant Data Visualisations Using the Grammar of Graphics.
55
+ AI-assisted port of the python **ggplot2** package — Create Elegant Data Visualisations Using the Grammar of Graphics.
56
56
 
57
57
  ## Overview
58
58
 
59
- ggplot2_py implements the grammar of graphics in Python, faithfully porting R's ggplot2 using pandas DataFrames as the data container and a Cairo-based rendering backend. It supports 47 geoms, 32 stats, faceting, coordinate systems, themes, guides, and 130+ scales.
59
+ ggplot2-python implements the grammar of graphics in Python, faithfully porting R's ggplot2 using pandas DataFrames as the data container and a Cairo-based rendering backend. It supports 47 geoms, 32 stats, faceting, coordinate systems, themes, guides, and 130+ scales.
60
60
 
61
- Beyond a direct port, ggplot2_py adds **Python-exclusive features** that extend the Grammar of Graphics with Python-native idioms while preserving full orthogonality of GOG components.
61
+ Beyond a direct port, ggplot2-python adds **Python-exclusive features** that extend the Grammar of Graphics with Python-native idioms while preserving full orthogonality of GOG components.
62
62
 
63
63
  ## Python-Exclusive Features
64
64
 
@@ -73,6 +73,7 @@ These capabilities have no R equivalent and leverage Python-specific language fe
73
73
  | **Auto-registration** | `__init_subclass__` | `class GeomStar(Geom): ...` auto-registers; no manual wiring needed |
74
74
  | **Protocol contracts** | `typing.Protocol` | `isinstance(my_geom, GeomProtocol)` — structural type checking for extensions |
75
75
  | **Scoped defaults** | `contextvars.ContextVar` | `with ggplot_defaults(theme=theme_minimal()): ...` — thread-safe scoped defaults |
76
+ | **Functional composition** | `sum` / `reduce` over `__add__` | `sum(parts, start=ggplot(data))` — compose plots without the `+` operator, useful for programmatic plot construction |
76
77
 
77
78
  ## Installation
78
79
 
@@ -85,7 +86,7 @@ For a local development:
85
86
 
86
87
  ```bash
87
88
  git clone https://github.com/Bio-Babel/ggplot2-python.git
88
- cd ggplot2_py
89
+ cd ggplot2-python
89
90
  pip install -e ".[dev]"
90
91
  ```
91
92
 
@@ -145,6 +146,46 @@ with ggplot_defaults(theme=theme_minimal()):
145
146
  # Outside: no defaults
146
147
  ```
147
148
 
149
+ ### Functional composition with `sum()` / `reduce()` (Python-exclusive)
150
+
151
+ The `+` operator is the canonical ggplot2 syntax. Because `GGPlot.__add__` is defined and every component family is registered with
152
+ the `update_ggplot` singledispatch generic, **Python's iterable-composition
153
+ idioms also work directly** — useful for programmatic plot building, list
154
+ comprehensions, or just function-style code:
155
+
156
+ ```python
157
+ # 1) `sum(parts, start=ggplot(data))` — the canonical function-style form
158
+ def fnplot(data, *parts):
159
+ return sum(parts, start=ggplot(data))
160
+
161
+ fnplot(
162
+ mpg,
163
+ aes(x="displ", y="hwy", colour="class"),
164
+ geom_point(),
165
+ geom_smooth(method="lm"),
166
+ facet_wrap("drv"),
167
+ theme_minimal(),
168
+ )
169
+
170
+ # 2) `sum` over an iterable, no helper needed:
171
+ sum(
172
+ [aes(x="displ", y="hwy"), geom_point(), theme_minimal()],
173
+ start=ggplot(mpg),
174
+ )
175
+
176
+ # 3) `functools.reduce` — the canonical Python composition operator:
177
+ from functools import reduce
178
+ from operator import add
179
+ reduce(add, [aes(x="displ", y="hwy"), geom_point(), theme_minimal()], ggplot(mpg))
180
+
181
+ # 4) List on the RHS of `+` — recursive add via the list-dispatch:
182
+ ggplot(mpg, aes("displ", "hwy")) + [geom_point(), geom_smooth(), theme_minimal()]
183
+ ```
184
+
185
+ > One caveat: Python's built-in `sum` has the signature
186
+ > `sum(iterable, /, start=0)` — it accepts *one* iterable plus an optional
187
+ > `start`, **not** variadic arguments. `sum(a, b, c, d)` raises `TypeError`;
188
+
148
189
  ## Tutorials
149
190
 
150
191
  ### User Tutorials
@@ -160,11 +201,11 @@ with ggplot_defaults(theme=theme_minimal()):
160
201
  - [Build Hooks](tutorials/build_hooks.ipynb) — intercepting the 16-stage build pipeline
161
202
 
162
203
  ### Developer Guide
163
- - [Developer Guide: Extending ggplot2_py](tutorials/developer_guide.ipynb) — comprehensive guide covering ggproto system, custom Stat/Geom creation, Protocol contracts, singledispatch, hooks, auto-registration, context manager, and packaging
204
+ - [Developer Guide: Extending ggplot2-python](tutorials/developer_guide.ipynb) — comprehensive guide covering ggproto system, custom Stat/Geom creation, Protocol contracts, singledispatch, hooks, auto-registration, context manager, and packaging
164
205
 
165
206
  ## Extension Architecture
166
207
 
167
- ggplot2_py is designed as an **extensible platform**. The following table summarises all extension points:
208
+ ggplot2-python is designed as an **extensible platform**. The following table summarises all extension points:
168
209
 
169
210
  | Extension point | Mechanism | How to use |
170
211
  |----------------|-----------|-----------|
@@ -1,4 +1,4 @@
1
- ggplot2_py/__init__.py,sha256=_mjXxq88zWT_Vw5R_L89MBmIYs7ijslG3zZBCkYOXU4,29435
1
+ ggplot2_py/__init__.py,sha256=kGTScY1HIhRm_caC48k6fj2CKmuf_tbxEDw35aPgcXE,29720
2
2
  ggplot2_py/_compat.py,sha256=G3f5pkIluuEF4F9Th25H03dON2hAKeFH6UJAnzUV2fM,12187
3
3
  ggplot2_py/_defaults.py,sha256=AzvkO4xCwscJutFdUxfk0KATdthR5yjzFs59FIcQr_w,6039
4
4
  ggplot2_py/_make_constructor.py,sha256=qwGBd3S1CYMfRsYNpVfvpzIlSAhNz_IMH6f5Ggo5Ix4,15481
@@ -23,14 +23,14 @@ ggplot2_py/labels.py,sha256=8JkSdSdpICcItyBv5RPh4PmzF4TzOnF5_umTHhul5Ro,8306
23
23
  ggplot2_py/layer.py,sha256=BjyWkbL1NNWGrBODpKuo-nYYqGs-jTxw3LKHq2ynbls,32748
24
24
  ggplot2_py/layout.py,sha256=QkaGP3anrQYS8vIFMlsz3jKmblHUTYGSevCXIMGlDns,25341
25
25
  ggplot2_py/limits.py,sha256=e1WezeXnqPUKn6aaNgyDkLkF1JbxustqfQorKrmjPHI,8219
26
- ggplot2_py/plot.py,sha256=tLjwLsR6dU5MIfWDXVigZxlY2wP46RIxj_ye_4EFCzg,42346
26
+ ggplot2_py/plot.py,sha256=WPhTYF48klfAB063XFOlTqZvyB1w5Kiff-UhUYnER2Q,49276
27
27
  ggplot2_py/plot_render.py,sha256=DP0gnDV-s_UHHBYiFdHgKgkhjLgO3oEZOO2vPGiMUKo,62864
28
28
  ggplot2_py/position.py,sha256=L2NSJ9TsVMlV1PjJtMsgFAuqi-f9fQ7epPdFVeKgtOk,34854
29
- ggplot2_py/protocols.py,sha256=7d34T7v7NLg3N1bSEPmmPFrV-5BZvnzBsbpiqnDatZA,5353
29
+ ggplot2_py/protocols.py,sha256=wUjEobsa_k65E5Put1fTXHgejnAIwkF6dBsIE63LaAE,7276
30
30
  ggplot2_py/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
31
  ggplot2_py/qplot.py,sha256=gZ6BWdU2bZwN1zwJCi9unOeWGDVEmV_FQPqb2X6-2TU,6551
32
- ggplot2_py/save.py,sha256=CVj-oZzdzUm14JQv6nQsaxgJcurDr1M-4Rjs2U0xoqQ,9055
33
- ggplot2_py/scale.py,sha256=Izn3dpjY9eE7cPHPmljk5XtpdbL1aA5zR-cmhrCk4Gc,88101
32
+ ggplot2_py/save.py,sha256=-VK4iN5JPri8pN2c0bvznaoTHncMQ_tmsDTqff8bdRs,9308
33
+ ggplot2_py/scale.py,sha256=9L6tDzQebchrIdhw4KR6qi8c0OcGgVbawl9anMDaLbs,90650
34
34
  ggplot2_py/stat.py,sha256=A83Gar5D6110UBvIa4EneLGP4wSlT2aXtp6Wf0DVdT0,212407
35
35
  ggplot2_py/theme.py,sha256=VHESBgKu64hw88Wxri2N5A19Uj3GeWkYim1hKcyDbk4,14066
36
36
  ggplot2_py/theme_defaults.py,sha256=BgvjPovcXN-oRcdyfYmT7hV9PhQG6XzcjzJ66Ruvb6E,37070
@@ -49,9 +49,9 @@ ggplot2_py/resources/msleep.csv,sha256=Cm5ArhLjOFXbU5Ubo3slSyZgv36TCjMECQY9L9dY2
49
49
  ggplot2_py/resources/presidential.csv,sha256=-IVpCt0WvBwYerzte6KOaDBpThxj2GIx49ChOoRcdfk,555
50
50
  ggplot2_py/resources/seals.csv,sha256=VEuSEBLz3Fj14eBdM6hp6M6GpH2lqN5ekT_d2Mk1rK0,57035
51
51
  ggplot2_py/resources/txhousing.csv,sha256=RdHoH5W9bud_DzsefIc8yNOFaxMl6IDDKMiP68aoIoY,523993
52
- ggplot2_py/scales/__init__.py,sha256=n1NLGW0cOeFxOYkj_yS2rvec4p9N1ZJBsugPG2TOHgY,100013
52
+ ggplot2_py/scales/__init__.py,sha256=ZXNYhLv1yVvjfu0K7awXR31aUq3Xe6E_JQ4melGnsok,100966
53
53
  ggplot2_py/stats/__init__.py,sha256=X_WSwq3jS2iJPGy7jEu_tXSQvVrIvC-5pm4ag2qTLVI,222
54
- ggplot2_python-4.0.2.9000.post3.dist-info/METADATA,sha256=iLJmmFph2oZLXvGkDhU11o3OkKk72VvdQCj8ivRvIO0,8336
55
- ggplot2_python-4.0.2.9000.post3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
56
- ggplot2_python-4.0.2.9000.post3.dist-info/licenses/LICENSE,sha256=E0GJjw07cYRmJYj_8UZKbhevWQ-Swd3nVR6qBddvz9c,39
57
- ggplot2_python-4.0.2.9000.post3.dist-info/RECORD,,
54
+ ggplot2_python-4.0.2.9000.post4.dist-info/METADATA,sha256=JODWYbilr8t5ox3fLqkSh00iUz4to5HjrIWcCJFfusA,9976
55
+ ggplot2_python-4.0.2.9000.post4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
56
+ ggplot2_python-4.0.2.9000.post4.dist-info/licenses/LICENSE,sha256=E0GJjw07cYRmJYj_8UZKbhevWQ-Swd3nVR6qBddvz9c,39
57
+ ggplot2_python-4.0.2.9000.post4.dist-info/RECORD,,