figrecipe 0.7.4__py3-none-any.whl → 0.9.0__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.
- figrecipe/__init__.py +74 -76
- figrecipe/__main__.py +12 -0
- figrecipe/_api/_panel.py +67 -0
- figrecipe/_api/_save.py +100 -4
- figrecipe/_cli/__init__.py +7 -0
- figrecipe/_cli/_compose.py +87 -0
- figrecipe/_cli/_convert.py +117 -0
- figrecipe/_cli/_crop.py +82 -0
- figrecipe/_cli/_edit.py +70 -0
- figrecipe/_cli/_extract.py +128 -0
- figrecipe/_cli/_fonts.py +47 -0
- figrecipe/_cli/_info.py +67 -0
- figrecipe/_cli/_main.py +58 -0
- figrecipe/_cli/_reproduce.py +79 -0
- figrecipe/_cli/_style.py +77 -0
- figrecipe/_cli/_validate.py +66 -0
- figrecipe/_cli/_version.py +50 -0
- figrecipe/_composition/__init__.py +32 -0
- figrecipe/_composition/_alignment.py +452 -0
- figrecipe/_composition/_compose.py +179 -0
- figrecipe/_composition/_import_axes.py +127 -0
- figrecipe/_composition/_visibility.py +125 -0
- figrecipe/_dev/__init__.py +2 -0
- figrecipe/_dev/browser/__init__.py +69 -0
- figrecipe/_dev/browser/_audio.py +240 -0
- figrecipe/_dev/browser/_caption.py +356 -0
- figrecipe/_dev/browser/_click_effect.py +146 -0
- figrecipe/_dev/browser/_cursor.py +196 -0
- figrecipe/_dev/browser/_highlight.py +105 -0
- figrecipe/_dev/browser/_narration.py +237 -0
- figrecipe/_dev/browser/_recorder.py +446 -0
- figrecipe/_dev/browser/_utils.py +178 -0
- figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
- figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
- figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
- figrecipe/_editor/__init__.py +36 -36
- figrecipe/_editor/_bbox/_extract.py +155 -9
- figrecipe/_editor/_bbox/_extract_text.py +124 -0
- figrecipe/_editor/_call_overrides.py +183 -0
- figrecipe/_editor/_datatable_plot_handlers.py +249 -0
- figrecipe/_editor/_figure_layout.py +211 -0
- figrecipe/_editor/_flask_app.py +157 -16
- figrecipe/_editor/_helpers.py +17 -8
- figrecipe/_editor/_hitmap/_detect.py +89 -32
- figrecipe/_editor/_hitmap_main.py +4 -4
- figrecipe/_editor/_overrides.py +4 -1
- figrecipe/_editor/_plot_types_registry.py +190 -0
- figrecipe/_editor/_render_overrides.py +38 -11
- figrecipe/_editor/_renderer.py +46 -1
- figrecipe/_editor/_routes_annotation.py +114 -0
- figrecipe/_editor/_routes_axis.py +35 -6
- figrecipe/_editor/_routes_captions.py +130 -0
- figrecipe/_editor/_routes_composition.py +270 -0
- figrecipe/_editor/_routes_core.py +15 -173
- figrecipe/_editor/_routes_datatable.py +364 -0
- figrecipe/_editor/_routes_element.py +37 -19
- figrecipe/_editor/_routes_files.py +443 -0
- figrecipe/_editor/_routes_image.py +200 -0
- figrecipe/_editor/_routes_snapshot.py +94 -0
- figrecipe/_editor/_routes_style.py +28 -8
- figrecipe/_editor/_templates/__init__.py +40 -2
- figrecipe/_editor/_templates/_html.py +97 -103
- figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
- figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
- figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
- figrecipe/_editor/_templates/_html_datatable.py +92 -0
- figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
- figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
- figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
- figrecipe/_editor/_templates/_scripts/_api.py +1 -1
- figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
- figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
- figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
- figrecipe/_editor/_templates/_scripts/_core.py +94 -37
- figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
- figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
- figrecipe/_editor/_templates/_scripts/_files.py +274 -40
- figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
- figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
- figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
- figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
- figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
- figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
- figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
- figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
- figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
- figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
- figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
- figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
- figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
- figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
- figrecipe/_editor/_templates/_styles/__init__.py +9 -0
- figrecipe/_editor/_templates/_styles/_base.py +47 -0
- figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
- figrecipe/_editor/_templates/_styles/_composition.py +87 -0
- figrecipe/_editor/_templates/_styles/_controls.py +168 -3
- figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
- figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
- figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
- figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
- figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
- figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
- figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
- figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
- figrecipe/_editor/_templates/_styles/_forms.py +98 -0
- figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
- figrecipe/_editor/_templates/_styles/_modals.py +29 -0
- figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
- figrecipe/_editor/_templates/_styles/_preview.py +213 -8
- figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
- figrecipe/_editor/static/audio/click.mp3 +0 -0
- figrecipe/_editor/static/click.mp3 +0 -0
- figrecipe/_editor/static/icons/favicon.ico +0 -0
- figrecipe/_integrations/__init__.py +17 -0
- figrecipe/_integrations/_scitex_stats.py +298 -0
- figrecipe/_params/_DECORATION_METHODS.py +2 -0
- figrecipe/_recorder.py +28 -3
- figrecipe/_reproducer/_core.py +60 -49
- figrecipe/_utils/__init__.py +3 -0
- figrecipe/_utils/_bundle.py +205 -0
- figrecipe/_wrappers/_axes.py +150 -2
- figrecipe/_wrappers/_caption_generator.py +218 -0
- figrecipe/_wrappers/_figure.py +26 -1
- figrecipe/_wrappers/_stat_annotation.py +274 -0
- figrecipe/styles/_style_applier.py +10 -2
- figrecipe/styles/presets/SCITEX.yaml +11 -4
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
- figrecipe-0.9.0.dist-info/RECORD +277 -0
- figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
- figrecipe-0.7.4.dist-info/RECORD +0 -188
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
figrecipe/_recorder.py
CHANGED
|
@@ -25,16 +25,21 @@ class CallRecord:
|
|
|
25
25
|
kwargs: Dict[str, Any]
|
|
26
26
|
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
27
27
|
ax_position: Tuple[int, int] = (0, 0)
|
|
28
|
+
# Statistics associated with this plot call (e.g., n, mean, sem)
|
|
29
|
+
stats: Optional[Dict[str, Any]] = None
|
|
28
30
|
|
|
29
31
|
def to_dict(self) -> Dict[str, Any]:
|
|
30
32
|
"""Convert to dictionary for serialization."""
|
|
31
|
-
|
|
33
|
+
result = {
|
|
32
34
|
"id": self.id,
|
|
33
35
|
"function": self.function,
|
|
34
36
|
"args": self.args,
|
|
35
37
|
"kwargs": self.kwargs,
|
|
36
38
|
"timestamp": self.timestamp,
|
|
37
39
|
}
|
|
40
|
+
if self.stats is not None:
|
|
41
|
+
result["stats"] = self.stats
|
|
42
|
+
return result
|
|
38
43
|
|
|
39
44
|
@classmethod
|
|
40
45
|
def from_dict(
|
|
@@ -48,6 +53,7 @@ class CallRecord:
|
|
|
48
53
|
kwargs=data["kwargs"],
|
|
49
54
|
timestamp=data.get("timestamp", ""),
|
|
50
55
|
ax_position=ax_position,
|
|
56
|
+
stats=data.get("stats"),
|
|
51
57
|
)
|
|
52
58
|
|
|
53
59
|
|
|
@@ -60,6 +66,10 @@ class AxesRecord:
|
|
|
60
66
|
decorations: List[CallRecord] = field(default_factory=list)
|
|
61
67
|
# Panel-level caption (e.g., "(A) Description of this panel")
|
|
62
68
|
caption: Optional[str] = None
|
|
69
|
+
# Panel-level statistics (e.g., summary stats, comparison results)
|
|
70
|
+
stats: Optional[Dict[str, Any]] = None
|
|
71
|
+
# Panel visibility (for composition)
|
|
72
|
+
visible: bool = True
|
|
63
73
|
|
|
64
74
|
def add_call(self, record: CallRecord) -> None:
|
|
65
75
|
"""Add a plotting call record."""
|
|
@@ -77,6 +87,10 @@ class AxesRecord:
|
|
|
77
87
|
}
|
|
78
88
|
if self.caption is not None:
|
|
79
89
|
result["caption"] = self.caption
|
|
90
|
+
if self.stats is not None:
|
|
91
|
+
result["stats"] = self.stats
|
|
92
|
+
if not self.visible: # Only serialize if hidden (default is True)
|
|
93
|
+
result["visible"] = False
|
|
80
94
|
return result
|
|
81
95
|
|
|
82
96
|
|
|
@@ -105,6 +119,8 @@ class FigureRecord:
|
|
|
105
119
|
# Metadata for scientific figures (not rendered, stored in recipe)
|
|
106
120
|
title_metadata: Optional[str] = None # Figure title for publication/reference
|
|
107
121
|
caption: Optional[str] = None # Figure caption (e.g., "Fig. 1. Description...")
|
|
122
|
+
# Figure-level statistics (e.g., comparisons across panels, summary)
|
|
123
|
+
stats: Optional[Dict[str, Any]] = None
|
|
108
124
|
|
|
109
125
|
def get_axes_key(self, row: int, col: int) -> str:
|
|
110
126
|
"""Get dictionary key for axes at position."""
|
|
@@ -157,6 +173,8 @@ class FigureRecord:
|
|
|
157
173
|
metadata["title"] = self.title_metadata
|
|
158
174
|
if self.caption is not None:
|
|
159
175
|
metadata["caption"] = self.caption
|
|
176
|
+
if self.stats is not None:
|
|
177
|
+
metadata["stats"] = self.stats
|
|
160
178
|
if metadata:
|
|
161
179
|
result["metadata"] = metadata
|
|
162
180
|
return result
|
|
@@ -181,6 +199,7 @@ class FigureRecord:
|
|
|
181
199
|
panel_labels=fig_data.get("panel_labels"),
|
|
182
200
|
title_metadata=metadata.get("title"),
|
|
183
201
|
caption=metadata.get("caption"),
|
|
202
|
+
stats=metadata.get("stats"),
|
|
184
203
|
)
|
|
185
204
|
|
|
186
205
|
# Reconstruct axes
|
|
@@ -195,6 +214,8 @@ class FigureRecord:
|
|
|
195
214
|
ax_record = AxesRecord(
|
|
196
215
|
position=(row, col),
|
|
197
216
|
caption=ax_data.get("caption"),
|
|
217
|
+
stats=ax_data.get("stats"),
|
|
218
|
+
visible=ax_data.get("visible", True),
|
|
198
219
|
)
|
|
199
220
|
for call_data in ax_data.get("calls", []):
|
|
200
221
|
ax_record.calls.append(CallRecord.from_dict(call_data, (row, col)))
|
|
@@ -271,6 +292,9 @@ class Recorder:
|
|
|
271
292
|
if call_id is None:
|
|
272
293
|
call_id = self._generate_call_id(method_name)
|
|
273
294
|
|
|
295
|
+
# Extract stats from kwargs before processing (stats is metadata, not matplotlib arg)
|
|
296
|
+
call_stats = kwargs.pop("stats", None) if "stats" in kwargs else None
|
|
297
|
+
|
|
274
298
|
# Process args into serializable format
|
|
275
299
|
processed_args = self._process_args(args, method_name)
|
|
276
300
|
|
|
@@ -283,6 +307,7 @@ class Recorder:
|
|
|
283
307
|
args=processed_args,
|
|
284
308
|
kwargs=processed_kwargs,
|
|
285
309
|
ax_position=ax_position,
|
|
310
|
+
stats=call_stats,
|
|
286
311
|
)
|
|
287
312
|
|
|
288
313
|
# Add to appropriate axes
|
|
@@ -365,8 +390,8 @@ class Recorder:
|
|
|
365
390
|
except Exception:
|
|
366
391
|
pass
|
|
367
392
|
|
|
368
|
-
# Remove internal keys
|
|
369
|
-
skip_keys = {"id", "track", "_array"}
|
|
393
|
+
# Remove internal keys (stats is handled separately as metadata)
|
|
394
|
+
skip_keys = {"id", "track", "_array", "stats"}
|
|
370
395
|
processed = {}
|
|
371
396
|
|
|
372
397
|
for key, value in kwargs.items():
|
figrecipe/_reproducer/_core.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
"""Core reproduction logic for figure reproduction."""
|
|
4
4
|
|
|
5
|
+
import shutil
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any, Dict, List, Optional, Union
|
|
7
8
|
|
|
@@ -11,6 +12,7 @@ from matplotlib.axes import Axes
|
|
|
11
12
|
|
|
12
13
|
from .._recorder import CallRecord, FigureRecord
|
|
13
14
|
from .._serializer import load_recipe
|
|
15
|
+
from .._utils._bundle import resolve_recipe_path
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
def reproduce(
|
|
@@ -24,8 +26,11 @@ def reproduce(
|
|
|
24
26
|
Parameters
|
|
25
27
|
----------
|
|
26
28
|
path : str or Path
|
|
27
|
-
Path to
|
|
28
|
-
|
|
29
|
+
Path to recipe. Supports multiple formats:
|
|
30
|
+
- .yaml/.yml file: Direct recipe file
|
|
31
|
+
- .png/.jpg/etc: Image with associated .yaml
|
|
32
|
+
- Directory: Bundle containing recipe.yaml
|
|
33
|
+
- .zip: ZIP archive containing recipe.yaml
|
|
29
34
|
calls : list of str, optional
|
|
30
35
|
If provided, only reproduce these specific call IDs.
|
|
31
36
|
skip_decorations : bool
|
|
@@ -43,56 +48,49 @@ def reproduce(
|
|
|
43
48
|
|
|
44
49
|
Examples
|
|
45
50
|
--------
|
|
46
|
-
>>> import figrecipe as
|
|
47
|
-
>>> fig, ax =
|
|
48
|
-
>>> fig, ax =
|
|
51
|
+
>>> import figrecipe as fr
|
|
52
|
+
>>> fig, ax = fr.reproduce("experiment_001.yaml")
|
|
53
|
+
>>> fig, ax = fr.reproduce("experiment_001.png") # Also works
|
|
54
|
+
>>> fig, ax = fr.reproduce("figure_bundle/") # Directory bundle
|
|
55
|
+
>>> fig, ax = fr.reproduce("figure.zip") # ZIP bundle
|
|
49
56
|
>>> plt.show()
|
|
50
57
|
"""
|
|
51
|
-
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)
|
|
58
|
+
# Resolve path to actual recipe YAML (handles directories, ZIPs, images)
|
|
59
|
+
path, temp_dir = resolve_recipe_path(path)
|
|
64
60
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
call.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
61
|
+
try:
|
|
62
|
+
record = load_recipe(path)
|
|
63
|
+
|
|
64
|
+
# Check for override file and merge if exists
|
|
65
|
+
if apply_overrides:
|
|
66
|
+
overrides_path = path.with_suffix(".overrides.json")
|
|
67
|
+
if overrides_path.exists():
|
|
68
|
+
import json
|
|
69
|
+
|
|
70
|
+
with open(overrides_path) as f:
|
|
71
|
+
data = json.load(f)
|
|
72
|
+
|
|
73
|
+
# Apply style overrides
|
|
74
|
+
manual_overrides = data.get("manual_overrides", {})
|
|
75
|
+
if manual_overrides:
|
|
76
|
+
if record.style is None:
|
|
77
|
+
record.style = {}
|
|
78
|
+
record.style.update(manual_overrides)
|
|
79
|
+
|
|
80
|
+
# Apply call overrides (kwargs changes from editor)
|
|
81
|
+
call_overrides = data.get("call_overrides", {})
|
|
82
|
+
if call_overrides:
|
|
83
|
+
for ax_key, ax_record in record.axes.items():
|
|
84
|
+
for call in ax_record.calls:
|
|
85
|
+
if call.id in call_overrides:
|
|
86
|
+
call.kwargs.update(call_overrides[call.id])
|
|
87
|
+
|
|
88
|
+
return reproduce_from_record(
|
|
89
|
+
record, calls=calls, skip_decorations=skip_decorations
|
|
90
|
+
)
|
|
91
|
+
finally:
|
|
92
|
+
if temp_dir is not None and temp_dir.exists():
|
|
93
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
96
94
|
|
|
97
95
|
|
|
98
96
|
def reproduce_from_record(
|
|
@@ -195,6 +193,10 @@ def reproduce_from_record(
|
|
|
195
193
|
if result is not None:
|
|
196
194
|
result_cache[call.id] = result
|
|
197
195
|
|
|
196
|
+
# Apply panel visibility
|
|
197
|
+
if not getattr(ax_record, "visible", True):
|
|
198
|
+
ax.set_visible(False)
|
|
199
|
+
|
|
198
200
|
# Finalize tick configuration and special plot types (avoids categorical axis interference)
|
|
199
201
|
from ..styles._style_applier import finalize_special_plots, finalize_ticks
|
|
200
202
|
|
|
@@ -314,6 +316,15 @@ def _replay_call(
|
|
|
314
316
|
|
|
315
317
|
return replay_swarmplot_call(ax, call)
|
|
316
318
|
|
|
319
|
+
# Handle stat_annotation specially (custom method)
|
|
320
|
+
if method_name == "stat_annotation":
|
|
321
|
+
from .._wrappers._stat_annotation import draw_stat_annotation
|
|
322
|
+
|
|
323
|
+
kwargs = call.kwargs.copy()
|
|
324
|
+
x1 = kwargs.pop("x1", 0)
|
|
325
|
+
x2 = kwargs.pop("x2", 1)
|
|
326
|
+
return draw_stat_annotation(ax, x1, x2, **kwargs)
|
|
327
|
+
|
|
317
328
|
method = getattr(ax, method_name, None)
|
|
318
329
|
|
|
319
330
|
if method is None:
|
figrecipe/_utils/__init__.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
"""Utility modules for figrecipe."""
|
|
4
4
|
|
|
5
|
+
from ._bundle import is_bundle_path, resolve_recipe_path
|
|
5
6
|
from ._diff import get_non_default_kwargs, is_default_value
|
|
6
7
|
from ._numpy_io import load_array, save_array
|
|
7
8
|
from ._units import inch_to_mm, mm_to_inch, mm_to_pt, pt_to_mm
|
|
@@ -15,6 +16,8 @@ __all__ = [
|
|
|
15
16
|
"inch_to_mm",
|
|
16
17
|
"mm_to_pt",
|
|
17
18
|
"pt_to_mm",
|
|
19
|
+
"resolve_recipe_path",
|
|
20
|
+
"is_bundle_path",
|
|
18
21
|
]
|
|
19
22
|
|
|
20
23
|
# Optional: image comparison (requires PIL)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Bundle and path resolution utilities for figrecipe.
|
|
4
|
+
|
|
5
|
+
This module provides utilities for resolving recipe paths from:
|
|
6
|
+
- Direct YAML files (.yaml, .yml)
|
|
7
|
+
- Image files (.png, .jpg, etc.) with associated YAML
|
|
8
|
+
- Bundle directories containing recipe.yaml
|
|
9
|
+
- ZIP files containing recipe.yaml
|
|
10
|
+
|
|
11
|
+
This enables integration with FTS (Figure Transfer Specification) bundles.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import tempfile
|
|
15
|
+
import zipfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional, Tuple, Union
|
|
18
|
+
|
|
19
|
+
# Standard recipe filename in bundles
|
|
20
|
+
RECIPE_FILENAME = "recipe.yaml"
|
|
21
|
+
RECIPE_FILENAME_ALT = "recipe.yml"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_recipe_path(
|
|
25
|
+
path: Union[str, Path],
|
|
26
|
+
extract_dir: Optional[Path] = None,
|
|
27
|
+
) -> Tuple[Path, Optional[Path]]:
|
|
28
|
+
"""Resolve a path to a recipe YAML file.
|
|
29
|
+
|
|
30
|
+
Handles multiple input formats:
|
|
31
|
+
- Direct YAML file: Returns as-is
|
|
32
|
+
- Image file (.png, etc.): Finds associated .yaml
|
|
33
|
+
- Directory: Looks for recipe.yaml inside
|
|
34
|
+
- ZIP file: Extracts and finds recipe.yaml
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
path : str or Path
|
|
39
|
+
Input path - can be YAML, image, directory, or ZIP.
|
|
40
|
+
extract_dir : Path, optional
|
|
41
|
+
Directory to extract ZIP contents to. If None, uses a temp directory.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
tuple
|
|
46
|
+
(recipe_path, temp_dir) where temp_dir is set if extraction occurred
|
|
47
|
+
and the caller should clean it up, or None if no cleanup needed.
|
|
48
|
+
|
|
49
|
+
Raises
|
|
50
|
+
------
|
|
51
|
+
FileNotFoundError
|
|
52
|
+
If path doesn't exist or recipe.yaml not found.
|
|
53
|
+
ValueError
|
|
54
|
+
If path type is not supported.
|
|
55
|
+
|
|
56
|
+
Examples
|
|
57
|
+
--------
|
|
58
|
+
>>> recipe_path, temp = resolve_recipe_path("figure.yaml")
|
|
59
|
+
>>> recipe_path, temp = resolve_recipe_path("figure/")
|
|
60
|
+
>>> recipe_path, temp = resolve_recipe_path("figure.zip")
|
|
61
|
+
"""
|
|
62
|
+
path = Path(path)
|
|
63
|
+
|
|
64
|
+
if not path.exists():
|
|
65
|
+
raise FileNotFoundError(f"Path not found: {path}")
|
|
66
|
+
|
|
67
|
+
# Case 1: Direct YAML file
|
|
68
|
+
if path.suffix.lower() in (".yaml", ".yml"):
|
|
69
|
+
return path, None
|
|
70
|
+
|
|
71
|
+
# Case 2: Image file - find associated YAML
|
|
72
|
+
if path.suffix.lower() in (
|
|
73
|
+
".png",
|
|
74
|
+
".jpg",
|
|
75
|
+
".jpeg",
|
|
76
|
+
".pdf",
|
|
77
|
+
".svg",
|
|
78
|
+
".tif",
|
|
79
|
+
".tiff",
|
|
80
|
+
):
|
|
81
|
+
return _resolve_from_image(path), None
|
|
82
|
+
|
|
83
|
+
# Case 3: Directory - look for recipe.yaml
|
|
84
|
+
if path.is_dir():
|
|
85
|
+
return _resolve_from_directory(path), None
|
|
86
|
+
|
|
87
|
+
# Case 4: ZIP file - extract and find recipe.yaml
|
|
88
|
+
if path.suffix.lower() == ".zip":
|
|
89
|
+
return _resolve_from_zip(path, extract_dir)
|
|
90
|
+
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Unsupported path type: {path.suffix}. "
|
|
93
|
+
f"Expected .yaml, .yml, .png, .zip, or directory."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolve_from_image(path: Path) -> Path:
|
|
98
|
+
"""Find YAML recipe associated with an image file."""
|
|
99
|
+
yaml_path = path.with_suffix(".yaml")
|
|
100
|
+
if yaml_path.exists():
|
|
101
|
+
return yaml_path
|
|
102
|
+
|
|
103
|
+
yml_path = path.with_suffix(".yml")
|
|
104
|
+
if yml_path.exists():
|
|
105
|
+
return yml_path
|
|
106
|
+
|
|
107
|
+
raise FileNotFoundError(
|
|
108
|
+
f"Recipe file not found for {path.name}. "
|
|
109
|
+
f"Expected {yaml_path.name} or {yml_path.name}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_from_directory(path: Path) -> Path:
|
|
114
|
+
"""Find recipe.yaml inside a directory (FTS bundle)."""
|
|
115
|
+
recipe_path = path / RECIPE_FILENAME
|
|
116
|
+
if recipe_path.exists():
|
|
117
|
+
return recipe_path
|
|
118
|
+
|
|
119
|
+
recipe_alt = path / RECIPE_FILENAME_ALT
|
|
120
|
+
if recipe_alt.exists():
|
|
121
|
+
return recipe_alt
|
|
122
|
+
|
|
123
|
+
# Also check for any .yaml file as fallback
|
|
124
|
+
yaml_files = list(path.glob("*.yaml")) + list(path.glob("*.yml"))
|
|
125
|
+
if len(yaml_files) == 1:
|
|
126
|
+
return yaml_files[0]
|
|
127
|
+
|
|
128
|
+
if yaml_files:
|
|
129
|
+
raise FileNotFoundError(
|
|
130
|
+
f"Multiple YAML files found in {path}. "
|
|
131
|
+
f"Expected {RECIPE_FILENAME} or a single .yaml file."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
raise FileNotFoundError(
|
|
135
|
+
f"No recipe found in directory {path}. "
|
|
136
|
+
f"Expected {RECIPE_FILENAME} or a .yaml file."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve_from_zip(
|
|
141
|
+
path: Path,
|
|
142
|
+
extract_dir: Optional[Path] = None,
|
|
143
|
+
) -> Tuple[Path, Path]:
|
|
144
|
+
"""Extract ZIP and find recipe.yaml inside."""
|
|
145
|
+
if not zipfile.is_zipfile(path):
|
|
146
|
+
raise ValueError(f"Not a valid ZIP file: {path}")
|
|
147
|
+
|
|
148
|
+
# Create extraction directory
|
|
149
|
+
if extract_dir is None:
|
|
150
|
+
extract_dir = Path(tempfile.mkdtemp(prefix="figrecipe_bundle_"))
|
|
151
|
+
|
|
152
|
+
with zipfile.ZipFile(path, "r") as zf:
|
|
153
|
+
# Find recipe.yaml in the ZIP
|
|
154
|
+
recipe_name = None
|
|
155
|
+
for name in zf.namelist():
|
|
156
|
+
basename = Path(name).name
|
|
157
|
+
if basename in (RECIPE_FILENAME, RECIPE_FILENAME_ALT):
|
|
158
|
+
recipe_name = name
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
if recipe_name is None:
|
|
162
|
+
# Try to find any yaml file
|
|
163
|
+
yaml_files = [n for n in zf.namelist() if n.endswith((".yaml", ".yml"))]
|
|
164
|
+
if len(yaml_files) == 1:
|
|
165
|
+
recipe_name = yaml_files[0]
|
|
166
|
+
elif yaml_files:
|
|
167
|
+
raise FileNotFoundError(
|
|
168
|
+
f"Multiple YAML files found in {path}. Expected {RECIPE_FILENAME}."
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
raise FileNotFoundError(
|
|
172
|
+
f"No recipe found in ZIP {path}. Expected {RECIPE_FILENAME}."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Extract all files (needed for data files referenced by recipe)
|
|
176
|
+
zf.extractall(extract_dir)
|
|
177
|
+
|
|
178
|
+
recipe_path = extract_dir / recipe_name
|
|
179
|
+
return recipe_path, extract_dir
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def is_bundle_path(path: Union[str, Path]) -> bool:
|
|
183
|
+
"""Check if path is a bundle directory or ZIP.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
path : str or Path
|
|
188
|
+
Path to check.
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
bool
|
|
193
|
+
True if path is a directory or ZIP file.
|
|
194
|
+
"""
|
|
195
|
+
path = Path(path)
|
|
196
|
+
if not path.exists():
|
|
197
|
+
return False
|
|
198
|
+
return path.is_dir() or path.suffix.lower() == ".zip"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = [
|
|
202
|
+
"resolve_recipe_path",
|
|
203
|
+
"is_bundle_path",
|
|
204
|
+
"RECIPE_FILENAME",
|
|
205
|
+
]
|
figrecipe/_wrappers/_axes.py
CHANGED
|
@@ -82,15 +82,26 @@ class RecordingAxes:
|
|
|
82
82
|
"""Create a wrapper function that records the call."""
|
|
83
83
|
from ._axes_helpers import record_call_with_color_capture
|
|
84
84
|
|
|
85
|
-
def wrapper(
|
|
85
|
+
def wrapper(
|
|
86
|
+
*args,
|
|
87
|
+
id: Optional[str] = None,
|
|
88
|
+
track: bool = True,
|
|
89
|
+
stats: Optional[Dict[str, Any]] = None,
|
|
90
|
+
**kwargs,
|
|
91
|
+
):
|
|
92
|
+
# Call matplotlib method (without stats - it's metadata only)
|
|
86
93
|
result = method(*args, **kwargs)
|
|
87
94
|
if self._track and track:
|
|
95
|
+
# Re-add stats to kwargs for recording
|
|
96
|
+
record_kwargs = kwargs.copy()
|
|
97
|
+
if stats is not None:
|
|
98
|
+
record_kwargs["stats"] = stats
|
|
88
99
|
record_call_with_color_capture(
|
|
89
100
|
self._recorder,
|
|
90
101
|
self._position,
|
|
91
102
|
method_name,
|
|
92
103
|
args,
|
|
93
|
-
|
|
104
|
+
record_kwargs,
|
|
94
105
|
result,
|
|
95
106
|
id,
|
|
96
107
|
self._result_refs,
|
|
@@ -134,6 +145,48 @@ class RecordingAxes:
|
|
|
134
145
|
ax_record = self._recorder.figure_record.get_or_create_axes(*self._position)
|
|
135
146
|
return ax_record.caption
|
|
136
147
|
|
|
148
|
+
def set_stats(self, stats: Dict[str, Any]) -> "RecordingAxes":
|
|
149
|
+
"""Set panel-level statistics metadata (not rendered, stored in recipe).
|
|
150
|
+
|
|
151
|
+
This is for storing statistical summary or comparison results
|
|
152
|
+
for this panel/axis, such as group means, sample sizes, or
|
|
153
|
+
comparison p-values.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
stats : dict
|
|
158
|
+
Statistics dictionary. Common keys include:
|
|
159
|
+
- n: sample size
|
|
160
|
+
- mean: mean value
|
|
161
|
+
- std: standard deviation
|
|
162
|
+
- sem: standard error of the mean
|
|
163
|
+
- comparisons: list of comparison results
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
RecordingAxes
|
|
168
|
+
Self for method chaining.
|
|
169
|
+
|
|
170
|
+
Examples
|
|
171
|
+
--------
|
|
172
|
+
>>> fig, axes = fr.subplots(1, 2)
|
|
173
|
+
>>> axes[0].set_stats({"n": 50, "mean": 3.2, "std": 1.1})
|
|
174
|
+
>>> axes[1].set_stats({
|
|
175
|
+
... "n": 48,
|
|
176
|
+
... "mean": 5.1,
|
|
177
|
+
... "comparisons": [{"vs": "control", "p_value": 0.003}]
|
|
178
|
+
... })
|
|
179
|
+
"""
|
|
180
|
+
ax_record = self._recorder.figure_record.get_or_create_axes(*self._position)
|
|
181
|
+
ax_record.stats = stats
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def stats(self) -> Optional[Dict[str, Any]]:
|
|
186
|
+
"""Get the panel-level statistics metadata."""
|
|
187
|
+
ax_record = self._recorder.figure_record.get_or_create_axes(*self._position)
|
|
188
|
+
return ax_record.stats
|
|
189
|
+
|
|
137
190
|
def no_record(self):
|
|
138
191
|
"""Context manager to temporarily disable recording.
|
|
139
192
|
|
|
@@ -333,6 +386,101 @@ class RecordingAxes:
|
|
|
333
386
|
ax_record = self._recorder.figure_record.get_or_create_axes(*self._position)
|
|
334
387
|
return ax_record.caption
|
|
335
388
|
|
|
389
|
+
def generate_panel_caption(
|
|
390
|
+
self, label: Optional[str] = None, style: str = "publication"
|
|
391
|
+
) -> str:
|
|
392
|
+
"""Generate a caption for this panel from stats metadata."""
|
|
393
|
+
from ._caption_generator import generate_panel_caption
|
|
394
|
+
|
|
395
|
+
return generate_panel_caption(label=label, stats=self.stats, style=style)
|
|
396
|
+
|
|
397
|
+
def add_stat_annotation(
|
|
398
|
+
self,
|
|
399
|
+
x1: float,
|
|
400
|
+
x2: float,
|
|
401
|
+
p_value: Optional[float] = None,
|
|
402
|
+
text: Optional[str] = None,
|
|
403
|
+
y: Optional[float] = None,
|
|
404
|
+
style: str = "stars",
|
|
405
|
+
bracket_height: Optional[float] = None,
|
|
406
|
+
text_offset: Optional[float] = None,
|
|
407
|
+
color: Optional[str] = None,
|
|
408
|
+
linewidth: Optional[float] = None,
|
|
409
|
+
fontsize: Optional[float] = None,
|
|
410
|
+
fontweight: Optional[str] = None,
|
|
411
|
+
id: Optional[str] = None,
|
|
412
|
+
track: bool = True,
|
|
413
|
+
**kwargs,
|
|
414
|
+
):
|
|
415
|
+
"""Add a statistical comparison annotation (bracket with stars/p-value).
|
|
416
|
+
|
|
417
|
+
Parameters
|
|
418
|
+
----------
|
|
419
|
+
x1, x2 : float
|
|
420
|
+
X positions of the two groups being compared.
|
|
421
|
+
p_value : float, optional
|
|
422
|
+
P-value for automatic star conversion.
|
|
423
|
+
text : str, optional
|
|
424
|
+
Custom text (overrides p_value formatting).
|
|
425
|
+
y : float, optional
|
|
426
|
+
Y position for bracket (auto-calculated if None).
|
|
427
|
+
style : str
|
|
428
|
+
"stars", "p_value", "both", or "bracket_only".
|
|
429
|
+
"""
|
|
430
|
+
from ._stat_annotation import draw_stat_annotation
|
|
431
|
+
|
|
432
|
+
# Draw the annotation
|
|
433
|
+
artists = draw_stat_annotation(
|
|
434
|
+
self._ax,
|
|
435
|
+
x1,
|
|
436
|
+
x2,
|
|
437
|
+
y=y,
|
|
438
|
+
text=text,
|
|
439
|
+
p_value=p_value,
|
|
440
|
+
style=style,
|
|
441
|
+
bracket_height=bracket_height,
|
|
442
|
+
text_offset=text_offset,
|
|
443
|
+
color=color,
|
|
444
|
+
linewidth=linewidth,
|
|
445
|
+
fontsize=fontsize,
|
|
446
|
+
fontweight=fontweight,
|
|
447
|
+
**kwargs,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Record if tracking
|
|
451
|
+
if self._track and track:
|
|
452
|
+
call_id = id if id else self._recorder._generate_call_id("stat_annotation")
|
|
453
|
+
record_kwargs = {
|
|
454
|
+
"x1": x1,
|
|
455
|
+
"x2": x2,
|
|
456
|
+
"p_value": p_value,
|
|
457
|
+
"text": text,
|
|
458
|
+
"y": y,
|
|
459
|
+
"style": style,
|
|
460
|
+
"bracket_height": bracket_height,
|
|
461
|
+
"text_offset": text_offset,
|
|
462
|
+
"color": color,
|
|
463
|
+
"linewidth": linewidth,
|
|
464
|
+
"fontsize": fontsize,
|
|
465
|
+
}
|
|
466
|
+
record_kwargs.update(kwargs)
|
|
467
|
+
# Remove None values
|
|
468
|
+
record_kwargs = {k: v for k, v in record_kwargs.items() if v is not None}
|
|
469
|
+
|
|
470
|
+
from .._recorder import CallRecord
|
|
471
|
+
|
|
472
|
+
record = CallRecord(
|
|
473
|
+
id=call_id,
|
|
474
|
+
function="stat_annotation",
|
|
475
|
+
args=[],
|
|
476
|
+
kwargs=record_kwargs,
|
|
477
|
+
ax_position=self._position,
|
|
478
|
+
)
|
|
479
|
+
ax_record = self._recorder.figure_record.get_or_create_axes(*self._position)
|
|
480
|
+
ax_record.add_decoration(record)
|
|
481
|
+
|
|
482
|
+
return artists
|
|
483
|
+
|
|
336
484
|
|
|
337
485
|
class _NoRecordContext:
|
|
338
486
|
"""Context manager to temporarily disable recording."""
|