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 +6 -1
- ggplot2_py/plot.py +172 -40
- ggplot2_py/protocols.py +106 -75
- ggplot2_py/save.py +8 -3
- ggplot2_py/scale.py +55 -4
- ggplot2_py/scales/__init__.py +25 -3
- {ggplot2_python-4.0.2.9000.post3.dist-info → ggplot2_python-4.0.2.9000.post4.dist-info}/METADATA +49 -8
- {ggplot2_python-4.0.2.9000.post3.dist-info → ggplot2_python-4.0.2.9000.post4.dist-info}/RECORD +10 -10
- {ggplot2_python-4.0.2.9000.post3.dist-info → ggplot2_python-4.0.2.9000.post4.dist-info}/WHEEL +0 -0
- {ggplot2_python-4.0.2.9000.post3.dist-info → ggplot2_python-4.0.2.9000.post4.dist-info}/licenses/LICENSE +0 -0
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.
|
|
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
|
-
#
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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) ->
|
|
307
|
-
per-layer data list. Return a new list to replace it
|
|
308
|
-
``None``
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
40
|
+
Static (mypy / pyright)::
|
|
15
41
|
|
|
16
42
|
class MyStat(Stat):
|
|
17
43
|
required_aes = ("x",)
|
|
18
|
-
#
|
|
44
|
+
# Forgetting compute_group is a static type error.
|
|
19
45
|
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
draw_key: Any
|
|
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:
|
|
75
|
-
def setup_data(self, data: pd.DataFrame, params:
|
|
76
|
-
def draw_panel(self,
|
|
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
|
-
|
|
89
|
-
``
|
|
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:
|
|
96
|
-
def setup_data(self, data: pd.DataFrame, params:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
132
|
-
|
|
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:
|
|
136
|
-
def transform(self, data: pd.DataFrame, panel_params:
|
|
137
|
-
def setup_panel_params(
|
|
138
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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,
|
|
11
|
+
from typing import Any, Dict, Optional, Union
|
|
12
12
|
|
|
13
|
-
from ggplot2_py._compat import cli_abort,
|
|
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,
|
|
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
|
-
|
|
1156
|
-
|
|
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:
|
ggplot2_py/scales/__init__.py
CHANGED
|
@@ -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) ->
|
|
378
|
-
|
|
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
|
|
{ggplot2_python-4.0.2.9000.post3.dist-info → ggplot2_python-4.0.2.9000.post4.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ggplot2-python
|
|
3
|
-
Version: 4.0.2.9000.
|
|
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
|
-
#
|
|
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
|
[](https://pypi.org/project/ggplot2-python/)
|
|
54
54
|
|
|
55
|
-
AI-assisted
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|----------------|-----------|-----------|
|
{ggplot2_python-4.0.2.9000.post3.dist-info → ggplot2_python-4.0.2.9000.post4.dist-info}/RECORD
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
ggplot2_py/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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
|
|
33
|
-
ggplot2_py/scale.py,sha256=
|
|
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=
|
|
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.
|
|
55
|
-
ggplot2_python-4.0.2.9000.
|
|
56
|
-
ggplot2_python-4.0.2.9000.
|
|
57
|
-
ggplot2_python-4.0.2.9000.
|
|
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,,
|
{ggplot2_python-4.0.2.9000.post3.dist-info → ggplot2_python-4.0.2.9000.post4.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|