figrecipe 0.6.0__py3-none-any.whl → 0.7.4__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.
Files changed (177) hide show
  1. figrecipe/__init__.py +106 -973
  2. figrecipe/_api/__init__.py +48 -0
  3. figrecipe/_api/_extract.py +108 -0
  4. figrecipe/_api/_notebook.py +61 -0
  5. figrecipe/_api/_panel.py +46 -0
  6. figrecipe/_api/_save.py +191 -0
  7. figrecipe/_api/_seaborn_proxy.py +34 -0
  8. figrecipe/_api/_style_manager.py +153 -0
  9. figrecipe/_api/_subplots.py +333 -0
  10. figrecipe/_api/_validate.py +82 -0
  11. figrecipe/_dev/__init__.py +2 -93
  12. figrecipe/_dev/_plotters.py +76 -0
  13. figrecipe/_dev/_run_demos.py +56 -0
  14. figrecipe/_dev/demo_plotters/__init__.py +35 -166
  15. figrecipe/_dev/demo_plotters/_categories.py +81 -0
  16. figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
  17. figrecipe/_dev/demo_plotters/_helpers.py +31 -0
  18. figrecipe/_dev/demo_plotters/_registry.py +50 -0
  19. figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
  20. figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
  21. figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
  22. figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
  23. figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
  24. figrecipe/_dev/demo_plotters/{plot_plot.py → line_curve/plot_plot.py} +3 -2
  25. figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
  26. figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
  27. figrecipe/_dev/demo_plotters/{plot_pie.py → special/plot_pie.py} +5 -1
  28. figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
  29. figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
  30. figrecipe/_editor/__init__.py +57 -9
  31. figrecipe/_editor/_bbox/__init__.py +43 -0
  32. figrecipe/_editor/_bbox/_collections.py +177 -0
  33. figrecipe/_editor/_bbox/_elements.py +159 -0
  34. figrecipe/_editor/_bbox/_extract.py +256 -0
  35. figrecipe/_editor/_bbox/_extract_axes.py +370 -0
  36. figrecipe/_editor/_bbox/_extract_text.py +342 -0
  37. figrecipe/_editor/_bbox/_lines.py +173 -0
  38. figrecipe/_editor/_bbox/_transforms.py +146 -0
  39. figrecipe/_editor/_flask_app.py +68 -1039
  40. figrecipe/_editor/_helpers.py +242 -0
  41. figrecipe/_editor/_hitmap/__init__.py +76 -0
  42. figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
  43. figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
  44. figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
  45. figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
  46. figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
  47. figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
  48. figrecipe/_editor/_hitmap/_colors.py +181 -0
  49. figrecipe/_editor/_hitmap/_detect.py +137 -0
  50. figrecipe/_editor/_hitmap/_restore.py +154 -0
  51. figrecipe/_editor/_hitmap_main.py +182 -0
  52. figrecipe/_editor/_preferences.py +135 -0
  53. figrecipe/_editor/_render_overrides.py +480 -0
  54. figrecipe/_editor/_renderer.py +35 -185
  55. figrecipe/_editor/_routes_axis.py +453 -0
  56. figrecipe/_editor/_routes_core.py +284 -0
  57. figrecipe/_editor/_routes_element.py +317 -0
  58. figrecipe/_editor/_routes_style.py +223 -0
  59. figrecipe/_editor/_templates/__init__.py +78 -1
  60. figrecipe/_editor/_templates/_html.py +109 -13
  61. figrecipe/_editor/_templates/_scripts/__init__.py +120 -0
  62. figrecipe/_editor/_templates/_scripts/_api.py +228 -0
  63. figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
  64. figrecipe/_editor/_templates/_scripts/_core.py +436 -0
  65. figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
  66. figrecipe/_editor/_templates/_scripts/_element_editor.py +310 -0
  67. figrecipe/_editor/_templates/_scripts/_files.py +195 -0
  68. figrecipe/_editor/_templates/_scripts/_hitmap.py +509 -0
  69. figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
  70. figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
  71. figrecipe/_editor/_templates/_scripts/_legend_drag.py +265 -0
  72. figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
  73. figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
  74. figrecipe/_editor/_templates/_scripts/_panel_drag.py +334 -0
  75. figrecipe/_editor/_templates/_scripts/_panel_position.py +279 -0
  76. figrecipe/_editor/_templates/_scripts/_selection.py +237 -0
  77. figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
  78. figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
  79. figrecipe/_editor/_templates/_scripts/_zoom.py +179 -0
  80. figrecipe/_editor/_templates/_styles/__init__.py +69 -0
  81. figrecipe/_editor/_templates/_styles/_base.py +64 -0
  82. figrecipe/_editor/_templates/_styles/_buttons.py +206 -0
  83. figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
  84. figrecipe/_editor/_templates/_styles/_controls.py +265 -0
  85. figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
  86. figrecipe/_editor/_templates/_styles/_forms.py +126 -0
  87. figrecipe/_editor/_templates/_styles/_hitmap.py +184 -0
  88. figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
  89. figrecipe/_editor/_templates/_styles/_labels.py +118 -0
  90. figrecipe/_editor/_templates/_styles/_modals.py +98 -0
  91. figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
  92. figrecipe/_editor/_templates/_styles/_preview.py +225 -0
  93. figrecipe/_editor/_templates/_styles/_selection.py +73 -0
  94. figrecipe/_params/_DECORATION_METHODS.py +6 -0
  95. figrecipe/_recorder.py +35 -106
  96. figrecipe/_recorder_utils.py +124 -0
  97. figrecipe/_reproducer/__init__.py +18 -0
  98. figrecipe/_reproducer/_core.py +498 -0
  99. figrecipe/_reproducer/_custom_plots.py +279 -0
  100. figrecipe/_reproducer/_seaborn.py +100 -0
  101. figrecipe/_reproducer/_violin.py +186 -0
  102. figrecipe/_signatures/_kwargs.py +273 -0
  103. figrecipe/_signatures/_loader.py +21 -423
  104. figrecipe/_signatures/_parsing.py +147 -0
  105. figrecipe/_wrappers/_axes.py +119 -910
  106. figrecipe/_wrappers/_axes_helpers.py +136 -0
  107. figrecipe/_wrappers/_axes_plots.py +418 -0
  108. figrecipe/_wrappers/_axes_seaborn.py +157 -0
  109. figrecipe/_wrappers/_figure.py +162 -0
  110. figrecipe/_wrappers/_panel_labels.py +127 -0
  111. figrecipe/_wrappers/_plot_helpers.py +143 -0
  112. figrecipe/_wrappers/_violin_helpers.py +180 -0
  113. figrecipe/styles/__init__.py +8 -6
  114. figrecipe/styles/_dotdict.py +72 -0
  115. figrecipe/styles/_finalize.py +134 -0
  116. figrecipe/styles/_fonts.py +77 -0
  117. figrecipe/styles/_kwargs_converter.py +178 -0
  118. figrecipe/styles/_plot_styles.py +209 -0
  119. figrecipe/styles/_style_applier.py +32 -478
  120. figrecipe/styles/_style_loader.py +16 -192
  121. figrecipe/styles/_themes.py +151 -0
  122. figrecipe/styles/presets/MATPLOTLIB.yaml +2 -1
  123. figrecipe/styles/presets/SCITEX.yaml +29 -24
  124. {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/METADATA +37 -2
  125. figrecipe-0.7.4.dist-info/RECORD +188 -0
  126. figrecipe/_editor/_bbox.py +0 -978
  127. figrecipe/_editor/_hitmap.py +0 -937
  128. figrecipe/_editor/_templates/_scripts.py +0 -2778
  129. figrecipe/_editor/_templates/_styles.py +0 -1326
  130. figrecipe/_reproducer.py +0 -975
  131. figrecipe-0.6.0.dist-info/RECORD +0 -90
  132. /figrecipe/_dev/demo_plotters/{plot_bar.py → bar_categorical/plot_bar.py} +0 -0
  133. /figrecipe/_dev/demo_plotters/{plot_barh.py → bar_categorical/plot_barh.py} +0 -0
  134. /figrecipe/_dev/demo_plotters/{plot_contour.py → contour_surface/plot_contour.py} +0 -0
  135. /figrecipe/_dev/demo_plotters/{plot_contourf.py → contour_surface/plot_contourf.py} +0 -0
  136. /figrecipe/_dev/demo_plotters/{plot_tricontour.py → contour_surface/plot_tricontour.py} +0 -0
  137. /figrecipe/_dev/demo_plotters/{plot_tricontourf.py → contour_surface/plot_tricontourf.py} +0 -0
  138. /figrecipe/_dev/demo_plotters/{plot_tripcolor.py → contour_surface/plot_tripcolor.py} +0 -0
  139. /figrecipe/_dev/demo_plotters/{plot_triplot.py → contour_surface/plot_triplot.py} +0 -0
  140. /figrecipe/_dev/demo_plotters/{plot_boxplot.py → distribution/plot_boxplot.py} +0 -0
  141. /figrecipe/_dev/demo_plotters/{plot_ecdf.py → distribution/plot_ecdf.py} +0 -0
  142. /figrecipe/_dev/demo_plotters/{plot_hist.py → distribution/plot_hist.py} +0 -0
  143. /figrecipe/_dev/demo_plotters/{plot_hist2d.py → distribution/plot_hist2d.py} +0 -0
  144. /figrecipe/_dev/demo_plotters/{plot_violinplot.py → distribution/plot_violinplot.py} +0 -0
  145. /figrecipe/_dev/demo_plotters/{plot_hexbin.py → image_matrix/plot_hexbin.py} +0 -0
  146. /figrecipe/_dev/demo_plotters/{plot_imshow.py → image_matrix/plot_imshow.py} +0 -0
  147. /figrecipe/_dev/demo_plotters/{plot_matshow.py → image_matrix/plot_matshow.py} +0 -0
  148. /figrecipe/_dev/demo_plotters/{plot_pcolor.py → image_matrix/plot_pcolor.py} +0 -0
  149. /figrecipe/_dev/demo_plotters/{plot_pcolormesh.py → image_matrix/plot_pcolormesh.py} +0 -0
  150. /figrecipe/_dev/demo_plotters/{plot_spy.py → image_matrix/plot_spy.py} +0 -0
  151. /figrecipe/_dev/demo_plotters/{plot_errorbar.py → line_curve/plot_errorbar.py} +0 -0
  152. /figrecipe/_dev/demo_plotters/{plot_fill.py → line_curve/plot_fill.py} +0 -0
  153. /figrecipe/_dev/demo_plotters/{plot_fill_between.py → line_curve/plot_fill_between.py} +0 -0
  154. /figrecipe/_dev/demo_plotters/{plot_fill_betweenx.py → line_curve/plot_fill_betweenx.py} +0 -0
  155. /figrecipe/_dev/demo_plotters/{plot_stackplot.py → line_curve/plot_stackplot.py} +0 -0
  156. /figrecipe/_dev/demo_plotters/{plot_stairs.py → line_curve/plot_stairs.py} +0 -0
  157. /figrecipe/_dev/demo_plotters/{plot_step.py → line_curve/plot_step.py} +0 -0
  158. /figrecipe/_dev/demo_plotters/{plot_scatter.py → scatter_points/plot_scatter.py} +0 -0
  159. /figrecipe/_dev/demo_plotters/{plot_eventplot.py → special/plot_eventplot.py} +0 -0
  160. /figrecipe/_dev/demo_plotters/{plot_loglog.py → special/plot_loglog.py} +0 -0
  161. /figrecipe/_dev/demo_plotters/{plot_semilogx.py → special/plot_semilogx.py} +0 -0
  162. /figrecipe/_dev/demo_plotters/{plot_semilogy.py → special/plot_semilogy.py} +0 -0
  163. /figrecipe/_dev/demo_plotters/{plot_stem.py → special/plot_stem.py} +0 -0
  164. /figrecipe/_dev/demo_plotters/{plot_acorr.py → spectral_signal/plot_acorr.py} +0 -0
  165. /figrecipe/_dev/demo_plotters/{plot_angle_spectrum.py → spectral_signal/plot_angle_spectrum.py} +0 -0
  166. /figrecipe/_dev/demo_plotters/{plot_cohere.py → spectral_signal/plot_cohere.py} +0 -0
  167. /figrecipe/_dev/demo_plotters/{plot_csd.py → spectral_signal/plot_csd.py} +0 -0
  168. /figrecipe/_dev/demo_plotters/{plot_magnitude_spectrum.py → spectral_signal/plot_magnitude_spectrum.py} +0 -0
  169. /figrecipe/_dev/demo_plotters/{plot_phase_spectrum.py → spectral_signal/plot_phase_spectrum.py} +0 -0
  170. /figrecipe/_dev/demo_plotters/{plot_psd.py → spectral_signal/plot_psd.py} +0 -0
  171. /figrecipe/_dev/demo_plotters/{plot_specgram.py → spectral_signal/plot_specgram.py} +0 -0
  172. /figrecipe/_dev/demo_plotters/{plot_xcorr.py → spectral_signal/plot_xcorr.py} +0 -0
  173. /figrecipe/_dev/demo_plotters/{plot_barbs.py → vector_flow/plot_barbs.py} +0 -0
  174. /figrecipe/_dev/demo_plotters/{plot_quiver.py → vector_flow/plot_quiver.py} +0 -0
  175. /figrecipe/_dev/demo_plotters/{plot_streamplot.py → vector_flow/plot_streamplot.py} +0 -0
  176. {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/WHEEL +0 -0
  177. {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,498 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Core reproduction logic for figure reproduction."""
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, Union
7
+
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ from matplotlib.axes import Axes
11
+
12
+ from .._recorder import CallRecord, FigureRecord
13
+ from .._serializer import load_recipe
14
+
15
+
16
+ def reproduce(
17
+ path: Union[str, Path],
18
+ calls: Optional[List[str]] = None,
19
+ skip_decorations: bool = False,
20
+ apply_overrides: bool = True,
21
+ ):
22
+ """Reproduce a figure from a recipe file.
23
+
24
+ Parameters
25
+ ----------
26
+ path : str or Path
27
+ Path to .yaml or .png recipe file. If .png is provided,
28
+ the corresponding .yaml file will be loaded.
29
+ calls : list of str, optional
30
+ If provided, only reproduce these specific call IDs.
31
+ skip_decorations : bool
32
+ If True, skip decoration calls (labels, legends, etc.).
33
+ apply_overrides : bool
34
+ If True (default), apply .overrides.json if it exists.
35
+ This preserves manual GUI editor changes.
36
+
37
+ Returns
38
+ -------
39
+ fig : RecordingFigure
40
+ Reproduced figure (same type as subplots() returns).
41
+ axes : RecordingAxes or ndarray of RecordingAxes
42
+ Reproduced axes (single if 1x1, otherwise numpy array).
43
+
44
+ Examples
45
+ --------
46
+ >>> import figrecipe as ps
47
+ >>> fig, ax = ps.reproduce("experiment_001.yaml")
48
+ >>> fig, ax = ps.reproduce("experiment_001.png") # Also works
49
+ >>> plt.show()
50
+ """
51
+ path = Path(path)
52
+
53
+ # Accept both .png and .yaml - find the yaml file
54
+ if path.suffix.lower() in (".png", ".jpg", ".jpeg", ".pdf", ".svg"):
55
+ yaml_path = path.with_suffix(".yaml")
56
+ if not yaml_path.exists():
57
+ raise FileNotFoundError(
58
+ f"Recipe file not found: {yaml_path}. "
59
+ f"Expected .yaml file alongside {path}"
60
+ )
61
+ path = yaml_path
62
+
63
+ record = load_recipe(path)
64
+
65
+ # Check for override file and merge if exists
66
+ if apply_overrides:
67
+ overrides_path = path.with_suffix(".overrides.json")
68
+ if overrides_path.exists():
69
+ import json
70
+
71
+ with open(overrides_path) as f:
72
+ data = json.load(f)
73
+
74
+ # Apply style overrides
75
+ manual_overrides = data.get("manual_overrides", {})
76
+ if manual_overrides:
77
+ # Merge overrides into record style
78
+ if record.style is None:
79
+ record.style = {}
80
+ record.style.update(manual_overrides)
81
+
82
+ # Apply call overrides (kwargs changes from editor)
83
+ call_overrides = data.get("call_overrides", {})
84
+ if call_overrides:
85
+ for ax_key, ax_record in record.axes.items():
86
+ for call in ax_record.calls:
87
+ if call.id in call_overrides:
88
+ # Merge call kwargs overrides
89
+ call.kwargs.update(call_overrides[call.id])
90
+
91
+ return reproduce_from_record(
92
+ record,
93
+ calls=calls,
94
+ skip_decorations=skip_decorations,
95
+ )
96
+
97
+
98
+ def reproduce_from_record(
99
+ record: FigureRecord,
100
+ calls: Optional[List[str]] = None,
101
+ skip_decorations: bool = False,
102
+ ):
103
+ """Reproduce a figure from a FigureRecord.
104
+
105
+ Parameters
106
+ ----------
107
+ record : FigureRecord
108
+ The figure record to reproduce.
109
+ calls : list of str, optional
110
+ If provided, only reproduce these specific call IDs.
111
+ skip_decorations : bool
112
+ If True, skip decoration calls.
113
+
114
+ Returns
115
+ -------
116
+ fig : RecordingFigure
117
+ Reproduced figure (wrapped).
118
+ axes : RecordingAxes or ndarray of RecordingAxes
119
+ Reproduced axes (wrapped, numpy array for multi-axes).
120
+ """
121
+ from .._recorder import Recorder
122
+ from .._wrappers import RecordingAxes, RecordingFigure
123
+
124
+ # Determine grid size from axes positions
125
+ max_row = 0
126
+ max_col = 0
127
+ for ax_key in record.axes.keys():
128
+ parts = ax_key.split("_")
129
+ if len(parts) >= 3:
130
+ max_row = max(max_row, int(parts[1]))
131
+ max_col = max(max_col, int(parts[2]))
132
+
133
+ nrows = max_row + 1
134
+ ncols = max_col + 1
135
+
136
+ # Create figure
137
+ fig, mpl_axes = plt.subplots(
138
+ nrows,
139
+ ncols,
140
+ figsize=record.figsize,
141
+ dpi=record.dpi,
142
+ constrained_layout=record.constrained_layout,
143
+ )
144
+
145
+ # Apply layout if recorded (skip if constrained_layout is used)
146
+ if record.layout is not None and not record.constrained_layout:
147
+ fig.subplots_adjust(**record.layout)
148
+
149
+ # Ensure axes is 2D array
150
+ if nrows == 1 and ncols == 1:
151
+ axes_2d = np.array([[mpl_axes]])
152
+ else:
153
+ axes_2d = np.atleast_2d(mpl_axes)
154
+ if nrows == 1:
155
+ axes_2d = axes_2d.reshape(1, -1)
156
+ elif ncols == 1:
157
+ axes_2d = axes_2d.reshape(-1, 1)
158
+
159
+ # Apply style BEFORE replaying calls (to match original order:
160
+ # style is applied during subplots(), then user creates plots/decorations)
161
+ if record.style is not None:
162
+ from ..styles import apply_style_mm
163
+
164
+ for row in range(nrows):
165
+ for col in range(ncols):
166
+ apply_style_mm(axes_2d[row, col], record.style)
167
+
168
+ # Result cache for resolving references (e.g., clabel needs ContourSet from contour)
169
+ result_cache: Dict[str, Any] = {}
170
+
171
+ # Replay calls on each axes
172
+ for ax_key, ax_record in record.axes.items():
173
+ parts = ax_key.split("_")
174
+ if len(parts) >= 3:
175
+ row, col = int(parts[1]), int(parts[2])
176
+ else:
177
+ row, col = 0, 0
178
+
179
+ ax = axes_2d[row, col]
180
+
181
+ # Replay plotting calls
182
+ for call in ax_record.calls:
183
+ if calls is not None and call.id not in calls:
184
+ continue
185
+ result = _replay_call(ax, call, result_cache)
186
+ if result is not None:
187
+ result_cache[call.id] = result
188
+
189
+ # Replay decorations
190
+ if not skip_decorations:
191
+ for call in ax_record.decorations:
192
+ if calls is not None and call.id not in calls:
193
+ continue
194
+ result = _replay_call(ax, call, result_cache)
195
+ if result is not None:
196
+ result_cache[call.id] = result
197
+
198
+ # Finalize tick configuration and special plot types (avoids categorical axis interference)
199
+ from ..styles._style_applier import finalize_special_plots, finalize_ticks
200
+
201
+ for row in range(nrows):
202
+ for col in range(ncols):
203
+ finalize_ticks(axes_2d[row, col])
204
+ finalize_special_plots(axes_2d[row, col], record.style or {})
205
+
206
+ # Apply figure-level labels if recorded
207
+ if record.suptitle is not None:
208
+ text = record.suptitle.get("text", "")
209
+ kwargs = record.suptitle.get("kwargs", {}).copy()
210
+ # Only add y=1.02 if not using constrained_layout (which handles positioning)
211
+ if "y" not in kwargs and not record.constrained_layout:
212
+ kwargs["y"] = 1.02
213
+ fig.suptitle(text, **kwargs)
214
+
215
+ if record.supxlabel is not None:
216
+ text = record.supxlabel.get("text", "")
217
+ kwargs = record.supxlabel.get("kwargs", {})
218
+ fig.supxlabel(text, **kwargs)
219
+
220
+ if record.supylabel is not None:
221
+ text = record.supylabel.get("text", "")
222
+ kwargs = record.supylabel.get("kwargs", {})
223
+ fig.supylabel(text, **kwargs)
224
+
225
+ # Wrap in Recording types (same as subplots() returns)
226
+ recorder = Recorder()
227
+ recorder._figure_record = record
228
+
229
+ # Wrap axes in RecordingAxes
230
+ wrapped_axes = np.empty((nrows, ncols), dtype=object)
231
+ for i in range(nrows):
232
+ for j in range(ncols):
233
+ wrapped_axes[i, j] = RecordingAxes(axes_2d[i, j], recorder, position=(i, j))
234
+
235
+ # Create RecordingFigure
236
+ wrapped_fig = RecordingFigure(fig, recorder, wrapped_axes.tolist())
237
+
238
+ # Reproduce panel labels if recorded
239
+ if record.panel_labels is not None:
240
+ labels = record.panel_labels.get("labels")
241
+ loc = record.panel_labels.get("loc", "upper left")
242
+ offset = tuple(record.panel_labels.get("offset", (-0.1, 1.05)))
243
+ fontsize = record.panel_labels.get("fontsize")
244
+ fontweight = record.panel_labels.get("fontweight", "bold")
245
+ color = record.panel_labels.get("color")
246
+ extra_kwargs = record.panel_labels.get("kwargs", {})
247
+ if color is not None:
248
+ extra_kwargs["color"] = color
249
+ wrapped_fig.add_panel_labels(
250
+ labels=labels,
251
+ loc=loc,
252
+ offset=offset,
253
+ fontsize=fontsize,
254
+ fontweight=fontweight,
255
+ **extra_kwargs,
256
+ )
257
+
258
+ # Return in appropriate format (matching subplots() behavior)
259
+ if nrows == 1 and ncols == 1:
260
+ return wrapped_fig, wrapped_axes[0, 0]
261
+ elif nrows == 1:
262
+ return wrapped_fig, np.array(wrapped_axes[0], dtype=object)
263
+ elif ncols == 1:
264
+ return wrapped_fig, np.array(wrapped_axes[:, 0], dtype=object)
265
+ else:
266
+ return wrapped_fig, wrapped_axes
267
+
268
+
269
+ def _replay_call(
270
+ ax: Axes, call: CallRecord, result_cache: Optional[Dict[str, Any]] = None
271
+ ) -> Any:
272
+ """Replay a single call on an axes.
273
+
274
+ Parameters
275
+ ----------
276
+ ax : Axes
277
+ The matplotlib axes.
278
+ call : CallRecord
279
+ The call to replay.
280
+ result_cache : dict, optional
281
+ Cache mapping call_id -> result for resolving references.
282
+
283
+ Returns
284
+ -------
285
+ Any
286
+ Result of the matplotlib call.
287
+ """
288
+ if result_cache is None:
289
+ result_cache = {}
290
+
291
+ method_name = call.function
292
+
293
+ # Check if it's a seaborn call
294
+ if method_name.startswith("sns."):
295
+ from ._seaborn import replay_seaborn_call
296
+
297
+ return replay_seaborn_call(ax, call)
298
+
299
+ # Handle violinplot with inner option specially
300
+ if method_name == "violinplot":
301
+ from ._violin import replay_violinplot_call
302
+
303
+ return replay_violinplot_call(ax, call)
304
+
305
+ # Handle joyplot specially (custom method)
306
+ if method_name == "joyplot":
307
+ from ._custom_plots import replay_joyplot_call
308
+
309
+ return replay_joyplot_call(ax, call)
310
+
311
+ # Handle swarmplot specially (custom method)
312
+ if method_name == "swarmplot":
313
+ from ._custom_plots import replay_swarmplot_call
314
+
315
+ return replay_swarmplot_call(ax, call)
316
+
317
+ method = getattr(ax, method_name, None)
318
+
319
+ if method is None:
320
+ # Method not found, skip
321
+ return None
322
+
323
+ # Reconstruct args
324
+ args = []
325
+ for arg_data in call.args:
326
+ value = _reconstruct_value(arg_data, result_cache)
327
+ args.append(value)
328
+
329
+ # Get kwargs and reconstruct arrays
330
+ kwargs = _reconstruct_kwargs(call.kwargs)
331
+
332
+ # Handle special transform markers
333
+ if "transform" in kwargs:
334
+ transform_val = kwargs["transform"]
335
+ if transform_val == "axes":
336
+ kwargs["transform"] = ax.transAxes
337
+ elif transform_val == "data":
338
+ kwargs["transform"] = ax.transData
339
+ elif transform_val == "figure":
340
+ kwargs["transform"] = ax.figure.transFigure
341
+ # If it's already a Transform object or something else, leave it
342
+
343
+ # Call the method
344
+ try:
345
+ return method(*args, **kwargs)
346
+ except Exception as e:
347
+ # Log warning but continue
348
+ import warnings
349
+
350
+ warnings.warn(f"Failed to replay {method_name}: {e}")
351
+ return None
352
+
353
+
354
+ def _reconstruct_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]:
355
+ """Reconstruct kwargs, converting 2D lists back to numpy arrays.
356
+
357
+ Parameters
358
+ ----------
359
+ kwargs : dict
360
+ Raw kwargs from call record.
361
+
362
+ Returns
363
+ -------
364
+ dict
365
+ Kwargs with arrays properly reconstructed.
366
+ """
367
+ result = {}
368
+ for key, value in kwargs.items():
369
+ # Handle 'colors' parameter specially - must be a list for pie/bar/etc.
370
+ # A single color string like 'red' would be interpreted as ['r','e','d']
371
+ if key == "colors" and isinstance(value, str):
372
+ result[key] = [value]
373
+ elif isinstance(value, list) and len(value) > 0:
374
+ # Check if it's a 2D list (list of lists) - should be numpy array
375
+ if isinstance(value[0], list):
376
+ result[key] = np.array(value)
377
+ else:
378
+ # 1D list - could be array or just list, try to preserve
379
+ result[key] = value
380
+ else:
381
+ result[key] = value
382
+ return result
383
+
384
+
385
+ def _reconstruct_value(
386
+ arg_data: Dict[str, Any], result_cache: Optional[Dict[str, Any]] = None
387
+ ) -> Any:
388
+ """Reconstruct a value from serialized arg data.
389
+
390
+ Parameters
391
+ ----------
392
+ arg_data : dict
393
+ Serialized argument data.
394
+ result_cache : dict, optional
395
+ Cache mapping call_id -> result for resolving references.
396
+
397
+ Returns
398
+ -------
399
+ Any
400
+ Reconstructed value.
401
+ """
402
+ if result_cache is None:
403
+ result_cache = {}
404
+
405
+ # Check if we have a pre-loaded array
406
+ if "_loaded_array" in arg_data:
407
+ return arg_data["_loaded_array"]
408
+
409
+ data = arg_data.get("data")
410
+
411
+ # Check if it's a reference to another call's result (e.g., ContourSet for clabel)
412
+ if isinstance(data, dict) and "__ref__" in data:
413
+ ref_id = data["__ref__"]
414
+ if ref_id in result_cache:
415
+ return result_cache[ref_id]
416
+ else:
417
+ import warnings
418
+
419
+ warnings.warn(f"Could not resolve reference to {ref_id}")
420
+ return None
421
+
422
+ # Check if it's a list of arrays (e.g., boxplot, violinplot)
423
+ if arg_data.get("_is_array_list") and isinstance(data, list):
424
+ dtype = arg_data.get("dtype")
425
+ # Convert each inner list to numpy array
426
+ return [
427
+ np.array(arr_data, dtype=dtype if isinstance(dtype, str) else None)
428
+ for arr_data in data
429
+ ]
430
+
431
+ # If data is a list, convert to numpy array
432
+ if isinstance(data, list):
433
+ dtype = arg_data.get("dtype")
434
+ try:
435
+ return np.array(data, dtype=dtype if dtype else None)
436
+ except (TypeError, ValueError):
437
+ return np.array(data)
438
+
439
+ return data
440
+
441
+
442
+ def get_recipe_info(path: Union[str, Path]) -> Dict[str, Any]:
443
+ """Get information about a recipe without reproducing.
444
+
445
+ Parameters
446
+ ----------
447
+ path : str or Path
448
+ Path to .yaml recipe file.
449
+
450
+ Returns
451
+ -------
452
+ dict
453
+ Recipe information including:
454
+ - id: Figure ID
455
+ - created: Creation timestamp
456
+ - matplotlib_version: Version used
457
+ - figsize: Figure size
458
+ - n_axes: Number of axes
459
+ - calls: List of call IDs
460
+ """
461
+ record = load_recipe(path)
462
+
463
+ all_calls = []
464
+ for ax_record in record.axes.values():
465
+ for call in ax_record.calls:
466
+ all_calls.append(
467
+ {
468
+ "id": call.id,
469
+ "function": call.function,
470
+ "n_args": len(call.args),
471
+ "kwargs": list(call.kwargs.keys()),
472
+ }
473
+ )
474
+ for call in ax_record.decorations:
475
+ all_calls.append(
476
+ {
477
+ "id": call.id,
478
+ "function": call.function,
479
+ "type": "decoration",
480
+ }
481
+ )
482
+
483
+ return {
484
+ "id": record.id,
485
+ "created": record.created,
486
+ "matplotlib_version": record.matplotlib_version,
487
+ "figsize": record.figsize,
488
+ "dpi": record.dpi,
489
+ "n_axes": len(record.axes),
490
+ "calls": all_calls,
491
+ }
492
+
493
+
494
+ __all__ = [
495
+ "reproduce",
496
+ "reproduce_from_record",
497
+ "get_recipe_info",
498
+ ]