ggplot2-python 4.0.2.9000.post3__tar.gz → 4.0.2.9000.post5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/PKG-INFO +54 -8
  2. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/README.md +53 -7
  3. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/__init__.py +16 -3
  4. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/_compat.py +66 -9
  5. ggplot2_python-4.0.2.9000.post5/ggplot2_py/_env.py +160 -0
  6. ggplot2_python-4.0.2.9000.post3/ggplot2_py/guide_axis.py → ggplot2_python-4.0.2.9000.post5/ggplot2_py/_guide_axis.py +20 -9
  7. ggplot2_python-4.0.2.9000.post3/ggplot2_py/guide_colourbar.py → ggplot2_python-4.0.2.9000.post5/ggplot2_py/_guide_colourbar.py +1 -1
  8. ggplot2_python-4.0.2.9000.post3/ggplot2_py/guide_legend.py → ggplot2_python-4.0.2.9000.post5/ggplot2_py/_guide_legend.py +22 -27
  9. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/coord.py +3 -3
  10. ggplot2_python-4.0.2.9000.post5/ggplot2_py/extension/__init__.py +365 -0
  11. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/facet.py +2 -2
  12. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/geom.py +3 -6
  13. ggplot2_python-4.0.2.9000.post5/ggplot2_py/ggproto.py +653 -0
  14. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/guide.py +645 -39
  15. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/layer.py +8 -3
  16. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/plot.py +342 -45
  17. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/plot_render.py +58 -551
  18. ggplot2_python-4.0.2.9000.post5/ggplot2_py/protocols.py +202 -0
  19. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/save.py +8 -3
  20. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/scale.py +166 -82
  21. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/scales/__init__.py +25 -3
  22. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/theme_elements.py +107 -54
  23. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/pyproject.toml +1 -1
  24. ggplot2_python-4.0.2.9000.post3/ggplot2_py/ggproto.py +0 -329
  25. ggplot2_python-4.0.2.9000.post3/ggplot2_py/protocols.py +0 -171
  26. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/.gitattributes +0 -0
  27. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/.gitignore +0 -0
  28. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/LICENSE +0 -0
  29. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/_defaults.py +0 -0
  30. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/_make_constructor.py +0 -0
  31. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/_plugins.py +0 -0
  32. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/_utils.py +0 -0
  33. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/aes.py +0 -0
  34. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/annotation.py +0 -0
  35. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/autoplot.py +0 -0
  36. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/coords/__init__.py +0 -0
  37. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/datasets.py +0 -0
  38. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/draw_key.py +0 -0
  39. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/fortify.py +0 -0
  40. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/geoms/__init__.py +0 -0
  41. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/guides/__init__.py +0 -0
  42. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/labeller.py +0 -0
  43. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/labels.py +0 -0
  44. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/layout.py +0 -0
  45. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/limits.py +0 -0
  46. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/position.py +0 -0
  47. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/py.typed +0 -0
  48. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/qplot.py +0 -0
  49. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/resources/diamonds.csv +0 -0
  50. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/resources/economics.csv +0 -0
  51. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/resources/economics_long.csv +0 -0
  52. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/resources/faithfuld.csv +0 -0
  53. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/resources/luv_colours.csv +0 -0
  54. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/resources/midwest.csv +0 -0
  55. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/resources/mpg.csv +0 -0
  56. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/resources/msleep.csv +0 -0
  57. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/resources/presidential.csv +0 -0
  58. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/resources/seals.csv +0 -0
  59. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/resources/txhousing.csv +0 -0
  60. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/stat.py +0 -0
  61. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/stats/__init__.py +0 -0
  62. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/theme.py +0 -0
  63. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post5}/ggplot2_py/theme_defaults.py +0 -0
@@ -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.post5
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
  |----------------|-----------|-----------|
@@ -177,5 +218,10 @@ ggplot2_py is designed as an **extensible platform**. The following table summar
177
218
  | Custom `+` types | `@update_ggplot.register(MyClass)` | Register any Python class for the `+` operator |
178
219
  | Custom plot types | `@ggplot_build.register(MyPlot)` | Override the entire build pipeline |
179
220
  | Build hooks | `plot.add_build_hook(timing, stage, fn)` | Intercept data at any pipeline stage |
221
+ | Per-plot `+` hooks | `register_pre_add_hook(plot, hook)` | Per-plot transformer fires on the next `+`; supports stateful, self-removing hooks (R: `+.<dynamic_class>`) |
222
+ | Plot-env constructor injection | `plot.plot_env.push({...})` | Override `find_scale` / `add_defaults` per-plot — install a custom `scale_<aes>_<type>` constructor (R: `find_global`) |
223
+ | Instance-as-parent ggproto | `ggproto("Name", instance, method=fn)` | Clone a Geom/Stat/Scale instance with method overrides (R: `ggproto(NULL, inst, method = fn)`) |
224
+ | Explicit method binding | `bind_method(obj, name, fn)` | Bind an arbitrary callable as a method when its first arg isn't named `self` |
225
+ | Extension toolkit | `from ggplot2_py.extension import …` | Pre-built helpers (`clone_layer`, `rename_aes_in_*`, `protect`, `palette_for_aes`, …) for cross-cutting extensions |
180
226
  | Protocol validation | `isinstance(obj, GeomProtocol)` | Verify structural conformance |
181
227
  | Scoped defaults | `with ggplot_defaults(theme=...):` | Thread-safe scoped defaults |
@@ -1,14 +1,14 @@
1
- # 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>
1
+ # 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>
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/ggplot2-python)](https://pypi.org/project/ggplot2-python/)
4
4
 
5
- AI-assisted Python port of the R **ggplot2** package — Create Elegant Data Visualisations Using the Grammar of Graphics.
5
+ AI-assisted port of the python **ggplot2** package — Create Elegant Data Visualisations Using the Grammar of Graphics.
6
6
 
7
7
  ## Overview
8
8
 
9
- 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.
9
+ 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.
10
10
 
11
- 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.
11
+ 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.
12
12
 
13
13
  ## Python-Exclusive Features
14
14
 
@@ -23,6 +23,7 @@ These capabilities have no R equivalent and leverage Python-specific language fe
23
23
  | **Auto-registration** | `__init_subclass__` | `class GeomStar(Geom): ...` auto-registers; no manual wiring needed |
24
24
  | **Protocol contracts** | `typing.Protocol` | `isinstance(my_geom, GeomProtocol)` — structural type checking for extensions |
25
25
  | **Scoped defaults** | `contextvars.ContextVar` | `with ggplot_defaults(theme=theme_minimal()): ...` — thread-safe scoped defaults |
26
+ | **Functional composition** | `sum` / `reduce` over `__add__` | `sum(parts, start=ggplot(data))` — compose plots without the `+` operator, useful for programmatic plot construction |
26
27
 
27
28
  ## Installation
28
29
 
@@ -35,7 +36,7 @@ For a local development:
35
36
 
36
37
  ```bash
37
38
  git clone https://github.com/Bio-Babel/ggplot2-python.git
38
- cd ggplot2_py
39
+ cd ggplot2-python
39
40
  pip install -e ".[dev]"
40
41
  ```
41
42
 
@@ -95,6 +96,46 @@ with ggplot_defaults(theme=theme_minimal()):
95
96
  # Outside: no defaults
96
97
  ```
97
98
 
99
+ ### Functional composition with `sum()` / `reduce()` (Python-exclusive)
100
+
101
+ The `+` operator is the canonical ggplot2 syntax. Because `GGPlot.__add__` is defined and every component family is registered with
102
+ the `update_ggplot` singledispatch generic, **Python's iterable-composition
103
+ idioms also work directly** — useful for programmatic plot building, list
104
+ comprehensions, or just function-style code:
105
+
106
+ ```python
107
+ # 1) `sum(parts, start=ggplot(data))` — the canonical function-style form
108
+ def fnplot(data, *parts):
109
+ return sum(parts, start=ggplot(data))
110
+
111
+ fnplot(
112
+ mpg,
113
+ aes(x="displ", y="hwy", colour="class"),
114
+ geom_point(),
115
+ geom_smooth(method="lm"),
116
+ facet_wrap("drv"),
117
+ theme_minimal(),
118
+ )
119
+
120
+ # 2) `sum` over an iterable, no helper needed:
121
+ sum(
122
+ [aes(x="displ", y="hwy"), geom_point(), theme_minimal()],
123
+ start=ggplot(mpg),
124
+ )
125
+
126
+ # 3) `functools.reduce` — the canonical Python composition operator:
127
+ from functools import reduce
128
+ from operator import add
129
+ reduce(add, [aes(x="displ", y="hwy"), geom_point(), theme_minimal()], ggplot(mpg))
130
+
131
+ # 4) List on the RHS of `+` — recursive add via the list-dispatch:
132
+ ggplot(mpg, aes("displ", "hwy")) + [geom_point(), geom_smooth(), theme_minimal()]
133
+ ```
134
+
135
+ > One caveat: Python's built-in `sum` has the signature
136
+ > `sum(iterable, /, start=0)` — it accepts *one* iterable plus an optional
137
+ > `start`, **not** variadic arguments. `sum(a, b, c, d)` raises `TypeError`;
138
+
98
139
  ## Tutorials
99
140
 
100
141
  ### User Tutorials
@@ -110,11 +151,11 @@ with ggplot_defaults(theme=theme_minimal()):
110
151
  - [Build Hooks](tutorials/build_hooks.ipynb) — intercepting the 16-stage build pipeline
111
152
 
112
153
  ### Developer Guide
113
- - [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
154
+ - [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
114
155
 
115
156
  ## Extension Architecture
116
157
 
117
- ggplot2_py is designed as an **extensible platform**. The following table summarises all extension points:
158
+ ggplot2-python is designed as an **extensible platform**. The following table summarises all extension points:
118
159
 
119
160
  | Extension point | Mechanism | How to use |
120
161
  |----------------|-----------|-----------|
@@ -127,5 +168,10 @@ ggplot2_py is designed as an **extensible platform**. The following table summar
127
168
  | Custom `+` types | `@update_ggplot.register(MyClass)` | Register any Python class for the `+` operator |
128
169
  | Custom plot types | `@ggplot_build.register(MyPlot)` | Override the entire build pipeline |
129
170
  | Build hooks | `plot.add_build_hook(timing, stage, fn)` | Intercept data at any pipeline stage |
171
+ | Per-plot `+` hooks | `register_pre_add_hook(plot, hook)` | Per-plot transformer fires on the next `+`; supports stateful, self-removing hooks (R: `+.<dynamic_class>`) |
172
+ | Plot-env constructor injection | `plot.plot_env.push({...})` | Override `find_scale` / `add_defaults` per-plot — install a custom `scale_<aes>_<type>` constructor (R: `find_global`) |
173
+ | Instance-as-parent ggproto | `ggproto("Name", instance, method=fn)` | Clone a Geom/Stat/Scale instance with method overrides (R: `ggproto(NULL, inst, method = fn)`) |
174
+ | Explicit method binding | `bind_method(obj, name, fn)` | Bind an arbitrary callable as a method when its first arg isn't named `self` |
175
+ | Extension toolkit | `from ggplot2_py.extension import …` | Pre-built helpers (`clone_layer`, `rename_aes_in_*`, `protect`, `palette_for_aes`, …) for cross-cutting extensions |
130
176
  | Protocol validation | `isinstance(obj, GeomProtocol)` | Verify structural conformance |
131
177
  | Scoped defaults | `with ggplot_defaults(theme=...):` | Thread-safe scoped defaults |
@@ -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.post5"
11
11
  __r_commit__ = "c02c05a"
12
12
 
13
13
  # ---------------------------------------------------------------------------
@@ -23,7 +23,10 @@ from ggplot2_py.ggproto import (
23
23
  ggproto,
24
24
  ggproto_parent,
25
25
  is_ggproto,
26
+ fetch_ggproto,
27
+ bind_method,
26
28
  )
29
+ from ggplot2_py._env import PlotEnv
27
30
 
28
31
  # ---------------------------------------------------------------------------
29
32
  # Aesthetics
@@ -62,6 +65,8 @@ from ggplot2_py.plot import (
62
65
  ggplotGrob,
63
66
  ggplot_add,
64
67
  add_gg,
68
+ register_pre_add_hook,
69
+ unregister_pre_add_hook,
65
70
  get_last_plot,
66
71
  set_last_plot,
67
72
  last_plot,
@@ -734,7 +739,8 @@ __all__ = [
734
739
  # Version
735
740
  "__version__",
736
741
  # Core
737
- "GGProto", "ggproto", "ggproto_parent", "is_ggproto",
742
+ "GGProto", "ggproto", "ggproto_parent", "is_ggproto", "fetch_ggproto",
743
+ "bind_method", "PlotEnv",
738
744
  "Waiver", "waiver", "is_waiver",
739
745
  # Aesthetics
740
746
  "aes", "after_stat", "after_scale", "stage", "vars",
@@ -745,7 +751,9 @@ __all__ = [
745
751
  "Layer", "layer", "is_layer",
746
752
  # Plot
747
753
  "ggplot", "is_ggplot", "ggplot_build", "ggplot_gtable", "ggplotGrob",
748
- "ggplot_add", "add_gg", "get_last_plot", "set_last_plot", "last_plot",
754
+ "ggplot_add", "add_gg",
755
+ "register_pre_add_hook", "unregister_pre_add_hook",
756
+ "get_last_plot", "set_last_plot", "last_plot",
749
757
  "print_plot", "get_alt_text", "update_ggplot",
750
758
  # Introspection
751
759
  "get_layer_data", "get_layer_grob", "get_panel_scales",
@@ -962,6 +970,11 @@ __all__ = [
962
970
  "derive", "flip_data", "flipped_names", "has_flipped_aes",
963
971
  # Plugin discovery
964
972
  "discover_extensions", "list_extensions",
973
+ # Python-exclusive extension surface (README quickstart uses
974
+ # ``from ggplot2_py import *``; these would otherwise be invisible)
975
+ "ggplot_defaults", "BuildStage",
976
+ "GeomProtocol", "StatProtocol", "ScaleProtocol",
977
+ "CoordProtocol", "FacetProtocol", "PositionProtocol",
965
978
  ]
966
979
 
967
980
  # ---------------------------------------------------------------------------
@@ -8,6 +8,8 @@ ggplot2 relies on, adapted for idiomatic Python usage.
8
8
  from __future__ import annotations
9
9
 
10
10
  import importlib
11
+ import logging
12
+ import sys
11
13
  import warnings
12
14
  from typing import Any, NoReturn, Optional
13
15
 
@@ -41,6 +43,40 @@ __all__ = [
41
43
  # ---------------------------------------------------------------------------
42
44
  # CLI messaging helpers (rlang / cli replacements)
43
45
  # ---------------------------------------------------------------------------
46
+ #
47
+ # R distinguishes three output channels:
48
+ #
49
+ # * ``cli::cli_abort`` — error stream (``stop``) → Python ``raise``
50
+ # * ``cli::cli_warn`` — warning stream (``warning``) → ``warnings.warn(..., UserWarning)``
51
+ # * ``cli::cli_inform``— message stream (``message``) → ``logging.INFO``
52
+ #
53
+ # In R these are three separate streams: ``suppressWarnings`` does not
54
+ # silence messages, ``suppressMessages`` does not silence warnings, and
55
+ # the test framework's ``expect_message`` / ``expect_warning`` discriminate
56
+ # between them. This Python port matches that split — informational
57
+ # output (e.g. ``ggsave``'s "Saving …", ``stat_bin``'s "using bins = 30")
58
+ # routes through the standard :mod:`logging` module at INFO level so
59
+ # pytest does not count it as a warning, ``warnings.catch_warnings``
60
+ # does not capture it, and ``logging.getLogger("ggplot2_py").setLevel(
61
+ # logging.WARNING)`` mirrors R's ``suppressMessages``.
62
+
63
+ #: Package-level logger. Library convention says "do not configure the
64
+ #: root logger" — instead we attach a single :class:`logging.StreamHandler`
65
+ #: to *this* logger and set :attr:`Logger.propagate` to ``False``, so the
66
+ #: handler runs even when the user has not called
67
+ #: :func:`logging.basicConfig` and ours never duplicates onto theirs.
68
+ _logger: logging.Logger = logging.getLogger("ggplot2_py")
69
+ if not _logger.handlers:
70
+ _handler = logging.StreamHandler(stream=sys.stderr)
71
+ # R's ``message()`` prints the bare message text (no level/timestamp
72
+ # prefix); mirror that to keep ggplot2_py output indistinguishable
73
+ # from the R original.
74
+ _handler.setFormatter(logging.Formatter("%(message)s"))
75
+ _handler.setLevel(logging.INFO)
76
+ _logger.addHandler(_handler)
77
+ _logger.setLevel(logging.INFO)
78
+ _logger.propagate = False
79
+
44
80
 
45
81
  def cli_abort(
46
82
  message: str,
@@ -81,7 +117,13 @@ def cli_warn(
81
117
  call: Optional[str] = None,
82
118
  **kwargs: Any,
83
119
  ) -> None:
84
- """Issue a ``UserWarning`` with a formatted message.
120
+ """Issue a ``UserWarning`` R *warning* stream equivalent.
121
+
122
+ Counterpart of :func:`cli_inform`: this function targets the
123
+ Python warning stream (matching R ``cli::cli_warn`` / ``warning()``),
124
+ while informational output goes through :mod:`logging` at INFO
125
+ level. Keeping the two streams separate preserves R's
126
+ ``suppressWarnings`` / ``suppressMessages`` granularity.
85
127
 
86
128
  Parameters
87
129
  ----------
@@ -105,24 +147,39 @@ def cli_inform(
105
147
  call: Optional[str] = None,
106
148
  **kwargs: Any,
107
149
  ) -> None:
108
- """Emit an informational message via Python's :mod:`warnings`.
150
+ """Emit an informational message at ``logging.INFO`` level.
151
+
152
+ R analogue: ``cli::cli_inform`` / ``rlang::inform`` write to R's
153
+ *message* stream — informational output that is **distinct from**
154
+ R's *warning* stream (see :func:`cli_warn`). Python's natural
155
+ equivalent is the :mod:`logging` module at INFO level:
109
156
 
110
- Mirrors R ``cli::cli_inform`` / ``rlang::inform`` which always write
111
- to stderr regardless of session type. Routing through ``warnings``
112
- lets pytest, ``warnings.catch_warnings``, and user filters capture
113
- or silence the message matching the way R lets users wrap
114
- ``suppressMessages({...})`` around an expression.
157
+ * pytest does not count INFO records as warnings (its
158
+ ``--strict-warnings`` flag and "warnings summary" only track
159
+ :mod:`warnings`);
160
+ * ``warnings.catch_warnings`` does not capture them;
161
+ * the user opt-out parallels R's ``suppressMessages``
162
+ ``logging.getLogger("ggplot2_py").setLevel(logging.WARNING)``
163
+ silences cli_inform output without affecting cli_warn.
164
+
165
+ By default the package logger has a :class:`logging.StreamHandler`
166
+ attached at INFO level (R-faithful: ``cli_inform`` messages are
167
+ visible to stderr unless explicitly suppressed).
115
168
 
116
169
  Parameters
117
170
  ----------
118
171
  message : str
119
- Informational message.
172
+ Informational message. May contain ``{name}``-style placeholders.
120
173
  call : str, optional
121
174
  Name of the calling function (unused; matches R signature).
122
175
  **kwargs : Any
123
176
  Substitution values for placeholders in *message*.
124
177
  """
125
- warnings.warn(message, UserWarning, stacklevel=2)
178
+ try:
179
+ formatted = message.format(**kwargs) if kwargs else message
180
+ except (KeyError, IndexError):
181
+ formatted = message
182
+ _logger.info(formatted)
126
183
 
127
184
 
128
185
  # ---------------------------------------------------------------------------
@@ -0,0 +1,160 @@
1
+ """
2
+ PlotEnv — layered namespace lookup for scale/guide constructors.
3
+
4
+ Port of R's ``find_global(name, env, mode = "function")`` (R ref:
5
+ ``ggplot2/R/scale-type.R:39-54``). Quoting the R source:
6
+
7
+ Look for object first in parent environment and if not found, then in
8
+ ggplot2 namespace environment. This makes it possible to override
9
+ default scales by setting them in the parent environment.
10
+
11
+ In R, ``plot@plot_env`` is the environment where the plot was constructed
12
+ (typically ``parent.frame()`` at ``ggplot()`` call time), and a chain of
13
+ environments terminates in ``asNamespace("ggplot2")``. ggnewscale
14
+ exploits this by **injecting** ``scale_<bumped_aes>_<type>`` functions
15
+ into ``plot@plot_env`` (R ref:
16
+ ``ggnewscale/R/rename-aes.R:87-104``), so that when build-time
17
+ default-scale resolution runs, the injected constructor wins.
18
+
19
+ In Python, the closest equivalent is a list-of-namespaces walked in
20
+ order, with the ``ggplot2_py.scales`` module as the implicit fallback
21
+ layer. :class:`PlotEnv` encapsulates that — call sites pass a
22
+ ``PlotEnv`` to :func:`find_scale` / ``ScalesList.add_defaults`` /
23
+ ``ScalesList.add_missing``, and lookups walk the user-pushed layers
24
+ first.
25
+
26
+ A *namespace* is anything that responds to attribute access **or**
27
+ mapping ``__getitem__``. ``dict``, ``types.ModuleType``,
28
+ ``types.SimpleNamespace``, ``argparse.Namespace``, and arbitrary
29
+ objects implementing ``__getattr__`` all work.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import Any, Callable, List, Optional
35
+
36
+ __all__ = ["PlotEnv"]
37
+
38
+
39
+ class PlotEnv:
40
+ """Layered scale/guide-constructor lookup table for a plot.
41
+
42
+ Mirrors the *effective* behaviour of R's ``plot@plot_env`` + the
43
+ ``find_global`` walk: lookups proceed through the user-pushed
44
+ layers in **last-pushed-first-checked** order (LIFO), then fall
45
+ back to the ``ggplot2_py.scales`` module.
46
+
47
+ Each layer may be any object that supports either attribute access
48
+ (``getattr(layer, name)``) or mapping access (``layer[name]``); the
49
+ first that yields a non-``None`` value wins.
50
+
51
+ Examples
52
+ --------
53
+ >>> env = PlotEnv()
54
+ >>> env.push({"scale_colour_continuous": my_factory})
55
+ >>> env.lookup("scale_colour_continuous") is my_factory
56
+ True
57
+
58
+ The implicit fallback to ``ggplot2_py.scales`` is intentionally
59
+ **not** included in :attr:`layers` so that :meth:`clone` produces
60
+ an env with the same user-visible chain.
61
+ """
62
+
63
+ def __init__(self, *layers: Any) -> None:
64
+ # Layers are stored in **push order**. Lookup walks them in
65
+ # reverse so that the most recently pushed layer wins, matching
66
+ # R's environment-chain semantics (the immediate parent is
67
+ # checked before more ancestral ones).
68
+ self._layers: List[Any] = [l for l in layers if l is not None]
69
+
70
+ # -- Mutation ----------------------------------------------------------
71
+
72
+ def push(self, layer: Any) -> None:
73
+ """Add a new lookup layer. Last-pushed wins on conflicts.
74
+
75
+ Parameters
76
+ ----------
77
+ layer : object
78
+ Any dict-like or attribute-bearing namespace.
79
+ """
80
+ if layer is None:
81
+ return
82
+ self._layers.append(layer)
83
+
84
+ def pop(self) -> Optional[Any]:
85
+ """Remove and return the most-recently-pushed layer, or ``None``
86
+ if the chain is empty.
87
+ """
88
+ if not self._layers:
89
+ return None
90
+ return self._layers.pop()
91
+
92
+ # -- Query -------------------------------------------------------------
93
+
94
+ def lookup(self, name: str) -> Optional[Callable]:
95
+ """Resolve *name* through the layer chain, then the
96
+ ``ggplot2_py.scales`` fallback module.
97
+
98
+ Returns the first hit, or ``None`` if nothing matches.
99
+
100
+ Notes
101
+ -----
102
+ The fallback layer is searched **last** even when the lookup
103
+ chain is non-empty — matching R's
104
+ ``c(env, list(as_namespace("ggplot2")))`` (R ref:
105
+ ``scale-type.R:44``).
106
+ """
107
+ for layer in reversed(self._layers):
108
+ val = _ns_get(layer, name)
109
+ if val is not None:
110
+ return val
111
+ # Fallback: the package's own scale-constructor module. Import
112
+ # lazily to avoid an import cycle with scale.py at load time —
113
+ # the cycle only matters during module construction; at call
114
+ # time every module is loaded so the import never raises.
115
+ from ggplot2_py import scales as _scales_mod
116
+ return getattr(_scales_mod, name, None)
117
+
118
+ def __contains__(self, name: str) -> bool:
119
+ return self.lookup(name) is not None
120
+
121
+ # -- Plumbing ----------------------------------------------------------
122
+
123
+ def clone(self) -> "PlotEnv":
124
+ """Return a new :class:`PlotEnv` with the same layer chain.
125
+
126
+ The layers themselves are **not** deep-copied — mirrors R env
127
+ semantics where ``plot_clone`` does not snapshot the
128
+ environment contents.
129
+ """
130
+ new = PlotEnv()
131
+ new._layers = list(self._layers)
132
+ return new
133
+
134
+ @property
135
+ def layers(self) -> List[Any]:
136
+ """Read-only view of the layer chain (in push order)."""
137
+ return list(self._layers)
138
+
139
+ def __repr__(self) -> str:
140
+ return f"<PlotEnv layers={len(self._layers)}>"
141
+
142
+
143
+ def _ns_get(layer: Any, name: str) -> Any:
144
+ """Resolve *name* on *layer*, trying mapping then attribute access.
145
+
146
+ Returns the found value, or ``None`` if absent. Catches only the
147
+ natural "key absent" / "unsupported indexing" cases — anything else
148
+ propagates so real bugs surface (per the project rule against
149
+ over-broad fallback logic).
150
+ """
151
+ # Mapping path (dict and dict-like). ``str``/``bytes`` are
152
+ # excluded because their ``__getitem__`` is positional and would
153
+ # raise ``TypeError`` for non-integer keys.
154
+ if hasattr(layer, "__getitem__") and not isinstance(layer, (str, bytes)):
155
+ try:
156
+ return layer[name]
157
+ except (KeyError, TypeError):
158
+ pass
159
+ # Attribute path (modules, SimpleNamespace, regular objects).
160
+ return getattr(layer, name, None)
@@ -55,8 +55,8 @@ _PT: float = 72.27 / 25.4
55
55
  # Helpers
56
56
  # ---------------------------------------------------------------------------
57
57
 
58
- def _unit_to_cm(u: Unit) -> float:
59
- """Convert a Unit (possibly compound/sum) to cm.
58
+ def _unit_to_cm(u: Unit, axis: str = "height") -> float:
59
+ """Convert a Unit (possibly compound/sum) to cm along *axis*.
60
60
 
61
61
  ``convert_height/width`` can't handle compound ``"sum"`` units
62
62
  without a viewport context. This helper decomposes them into
@@ -70,8 +70,17 @@ def _unit_to_cm(u: Unit) -> float:
70
70
 
71
71
  The ``data`` element is itself a multi-element Unit with the
72
72
  individual operands.
73
+
74
+ Parameters
75
+ ----------
76
+ u : Unit
77
+ Unit to convert.
78
+ axis : {"height", "width"}
79
+ Axis to measure along — needed for viewport-relative units
80
+ (``"npc"``) which resolve differently per axis in non-square
81
+ viewports. Compound sums propagate the axis to children.
73
82
  """
74
- from grid_py import convert_height
83
+ from grid_py import convert_height, convert_width
75
84
 
76
85
  units = getattr(u, "_units", None)
77
86
  values = getattr(u, "_values", None)
@@ -79,6 +88,8 @@ def _unit_to_cm(u: Unit) -> float:
79
88
  if units is None or values is None:
80
89
  return 0.0
81
90
 
91
+ fn = convert_width if axis == "width" else convert_height
92
+
82
93
  total_cm = 0.0
83
94
  n = len(u)
84
95
  for i in range(n):
@@ -88,7 +99,7 @@ def _unit_to_cm(u: Unit) -> float:
88
99
  # The operands are stored as a multi-element Unit in data[i]
89
100
  inner = data[i] if data and i < len(data) else None
90
101
  if inner is not None and isinstance(inner, Unit):
91
- total_cm += _unit_to_cm(inner)
102
+ total_cm += _unit_to_cm(inner, axis)
92
103
  continue
93
104
 
94
105
  # Skip context-dependent units we can't resolve statically
@@ -99,7 +110,7 @@ def _unit_to_cm(u: Unit) -> float:
99
110
  val = float(values[i]) if i < len(values) else 0.0
100
111
  leaf = Unit(val, unit_type)
101
112
  try:
102
- cm = convert_height(leaf, "cm", valueOnly=True)
113
+ cm = fn(leaf, "cm", valueOnly=True)
103
114
  total_cm += float(np.sum(cm))
104
115
  except Exception:
105
116
  pass
@@ -125,12 +136,12 @@ def _width_cm(x: Any) -> float:
125
136
  # For compound (sum) units, convert_width returns bogus results;
126
137
  # decompose and convert leaf-by-leaf instead.
127
138
  if _has_sum_unit(u):
128
- return _unit_to_cm(u)
139
+ return _unit_to_cm(u, "width")
129
140
  try:
130
141
  result = convert_width(u, "cm", valueOnly=True)
131
142
  return float(np.sum(result))
132
143
  except Exception:
133
- return _unit_to_cm(u)
144
+ return _unit_to_cm(u, "width")
134
145
 
135
146
 
136
147
  def _height_cm(x: Any) -> float:
@@ -143,12 +154,12 @@ def _height_cm(x: Any) -> float:
143
154
  else:
144
155
  return 0.0
145
156
  if _has_sum_unit(u):
146
- return _unit_to_cm(u)
157
+ return _unit_to_cm(u, "height")
147
158
  try:
148
159
  result = convert_height(u, "cm", valueOnly=True)
149
160
  return float(np.sum(result))
150
161
  except Exception:
151
- return _unit_to_cm(u)
162
+ return _unit_to_cm(u, "height")
152
163
 
153
164
 
154
165
  # ---------------------------------------------------------------------------
@@ -681,7 +681,7 @@ def assemble_colourbar(
681
681
  gt = gtable_add_grob(gt, label_tree, t=1, l=1, clip="off", name="labels")
682
682
 
683
683
  # Add title
684
- from ggplot2_py.guide_legend import add_legend_title
684
+ from ggplot2_py._guide_legend import add_legend_title
685
685
  gt = add_legend_title(gt, title_grob, position="top")
686
686
 
687
687
  # Add padding