ggplot2-python 4.0.2.9000.post3__tar.gz → 4.0.2.9000.post4__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 (60) hide show
  1. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/PKG-INFO +49 -8
  2. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/README.md +48 -7
  3. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/__init__.py +6 -1
  4. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/plot.py +172 -40
  5. ggplot2_python-4.0.2.9000.post4/ggplot2_py/protocols.py +202 -0
  6. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/save.py +8 -3
  7. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/scale.py +55 -4
  8. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/scales/__init__.py +25 -3
  9. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/pyproject.toml +1 -1
  10. ggplot2_python-4.0.2.9000.post3/ggplot2_py/protocols.py +0 -171
  11. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/.gitattributes +0 -0
  12. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/.gitignore +0 -0
  13. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/LICENSE +0 -0
  14. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/_compat.py +0 -0
  15. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/_defaults.py +0 -0
  16. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/_make_constructor.py +0 -0
  17. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/_plugins.py +0 -0
  18. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/_utils.py +0 -0
  19. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/aes.py +0 -0
  20. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/annotation.py +0 -0
  21. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/autoplot.py +0 -0
  22. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/coord.py +0 -0
  23. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/coords/__init__.py +0 -0
  24. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/datasets.py +0 -0
  25. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/draw_key.py +0 -0
  26. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/facet.py +0 -0
  27. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/fortify.py +0 -0
  28. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/geom.py +0 -0
  29. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/geoms/__init__.py +0 -0
  30. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/ggproto.py +0 -0
  31. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/guide.py +0 -0
  32. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/guide_axis.py +0 -0
  33. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/guide_colourbar.py +0 -0
  34. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/guide_legend.py +0 -0
  35. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/guides/__init__.py +0 -0
  36. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/labeller.py +0 -0
  37. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/labels.py +0 -0
  38. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/layer.py +0 -0
  39. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/layout.py +0 -0
  40. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/limits.py +0 -0
  41. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/plot_render.py +0 -0
  42. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/position.py +0 -0
  43. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/py.typed +0 -0
  44. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/qplot.py +0 -0
  45. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/resources/diamonds.csv +0 -0
  46. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/resources/economics.csv +0 -0
  47. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/resources/economics_long.csv +0 -0
  48. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/resources/faithfuld.csv +0 -0
  49. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/resources/luv_colours.csv +0 -0
  50. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/resources/midwest.csv +0 -0
  51. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/resources/mpg.csv +0 -0
  52. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/resources/msleep.csv +0 -0
  53. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/resources/presidential.csv +0 -0
  54. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/resources/seals.csv +0 -0
  55. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/resources/txhousing.csv +0 -0
  56. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/stat.py +0 -0
  57. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/stats/__init__.py +0 -0
  58. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/theme.py +0 -0
  59. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/theme_defaults.py +0 -0
  60. {ggplot2_python-4.0.2.9000.post3 → ggplot2_python-4.0.2.9000.post4}/ggplot2_py/theme_elements.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.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,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
  |----------------|-----------|-----------|
@@ -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
  # ---------------------------------------------------------------------------
@@ -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)