Source code for scitex_bridge._figrecipe

#!/usr/bin/env python3
"""Bridge adapter for figrecipe integration.

This module provides functions to save figures with both:
- SigmaPlot-compatible CSV (scitex format)
- figrecipe YAML recipe (reproducible figures)

The FTS bundle structure:
    figure/
    ├── recipe.yaml     # Source of truth (figrecipe format)
    ├── recipe_data/    # Large arrays (if needed)
    ├── plot.csv        # SigmaPlot combined CSV (derived)
    ├── plot.png        # Primary image (derived)
    └── meta.yaml       # FTS metadata (optional)
"""

from pathlib import Path
from typing import Any, Dict, Optional, Union

from scitex_dev import try_import_optional

# Check figrecipe availability — figrecipe is not declared as a pip
# extra (it's a peer SciTeX package), so omit `extra=`/`pkg=`.
fr = try_import_optional("figrecipe")
_fr_save_recipe = try_import_optional("figrecipe._serializer", "save_recipe")
FIGRECIPE_AVAILABLE = fr is not None and _fr_save_recipe is not None


def _default_get_storage():
    """Resolve scitex.io.bundle's storage factory, or ``None`` if absent.

    Kept as a small seam so callers (and tests) can inject an alternative
    storage factory without patching the import machinery.
    """
    try:
        from scitex_io.bundle._bundle._storage import get_storage
    except ImportError:
        return None
    return get_storage


[docs] def save_with_recipe( fig, path: Union[str, Path], include_csv: bool = True, include_recipe: bool = True, data_format: str = "csv", dpi: int = 300, *, storage_resolver=_default_get_storage, **kwargs, ) -> Dict[str, Path]: """Save figure with both CSV and figrecipe recipe. Parameters ---------- fig : FigWrapper or matplotlib Figure The figure to save. path : str or Path Output path. Can be: - Directory path (creates bundle) - File path with .zip extension (creates zip bundle) - File path with image extension (saves image + sidecar files) include_csv : bool If True, save SigmaPlot-compatible CSV. include_recipe : bool If True, save figrecipe YAML recipe (requires figrecipe). data_format : str Format for recipe data: 'csv', 'npz', or 'inline'. dpi : int Resolution for image output. storage_resolver : callable Zero-arg resolver returning the bundle-storage factory, or ``None`` when scitex.io.bundle is absent. Defaults to :func:`_default_get_storage`; when it returns ``None`` the function degrades to ``{}``. **kwargs Additional arguments passed to savefig (including facecolor). Returns ------- dict Paths to saved files: {'image': Path, 'csv': Path, 'recipe': Path} """ get_storage = storage_resolver() if get_storage is None: return {} path = Path(path) result = {} # Determine if this is a bundle (directory or zip) is_bundle = path.suffix == ".zip" or path.suffix == "" or path.is_dir() if is_bundle: # Create bundle storage storage = get_storage(path) storage.ensure_exists() # 1. Save image - use fig.savefig() to get facecolor fix from FigWrapper image_path = storage.path / "plot.png" _save_figure_image(fig, image_path, dpi=dpi, **kwargs) result["image"] = image_path # 2. Save SigmaPlot CSV if include_csv and hasattr(fig, "export_as_csv"): try: csv_df = fig.export_as_csv() if not csv_df.empty: csv_path = storage.path / "plot.csv" csv_df.to_csv(csv_path, index=False) result["csv"] = csv_path except Exception: pass # CSV export is optional # 3. Save figrecipe recipe if include_recipe: recipe_path = _save_recipe_to_path( fig, storage.path / "recipe.yaml", data_format ) if recipe_path: result["recipe"] = recipe_path else: # Single file save (image + sidecars) _save_figure_image(fig, path, dpi=dpi, **kwargs) result["image"] = path # Save CSV sidecar if include_csv and hasattr(fig, "export_as_csv"): try: csv_df = fig.export_as_csv() if not csv_df.empty: csv_path = path.with_suffix(".csv") csv_df.to_csv(csv_path, index=False) result["csv"] = csv_path except Exception: pass # Save recipe sidecar if include_recipe: recipe_path = _save_recipe_to_path( fig, path.with_suffix(".yaml"), data_format ) if recipe_path: result["recipe"] = recipe_path return result
def _save_figure_image(fig, path: Path, dpi: int = 300, **kwargs): """Save figure image using the best available method with facecolor support. Uses fig.savefig() when available (FigWrapper or RecordingFigure) to get the facecolor override fix for transparent figures. """ # Check if this is a figrecipe RecordingFigure - use fr.save() for full support if FIGRECIPE_AVAILABLE: try: from figrecipe.utils import RecordingFigure if isinstance(fig, RecordingFigure): # Use figrecipe's save with facecolor support facecolor = kwargs.pop("facecolor", None) fr.save( fig, path, save_recipe=False, # Recipe saved separately dpi=dpi, facecolor=facecolor, verbose=False, **kwargs, ) return except (ImportError, AttributeError): pass # Use fig.savefig() if available (FigWrapper has facecolor fix) if hasattr(fig, "savefig"): fig.savefig(path, dpi=dpi, **kwargs) else: # Fallback to matplotlib figure's savefig mpl_fig = fig._fig_mpl if hasattr(fig, "_fig_mpl") else fig mpl_fig.savefig(path, dpi=dpi, **kwargs) def _save_recipe_to_path( fig, path: Path, data_format: str = "csv", ) -> Optional[Path]: """Save figrecipe recipe if available. Parameters ---------- fig : FigWrapper Figure with optional _figrecipe_recorder attribute. path : Path Output path for recipe.yaml. data_format : str Format for data: 'csv', 'npz', or 'inline'. Returns ------- Path or None Path to saved recipe, or None if not available. """ if not FIGRECIPE_AVAILABLE: return None try: # Check if figure has figrecipe recorder if hasattr(fig, "_figrecipe_recorder") and fig._figrecipe_enabled: recorder = fig._figrecipe_recorder figure_record = recorder.figure_record # Capture current figure state into record _capture_figure_state(fig, figure_record) # Save using figrecipe's serializer _fr_save_recipe( figure_record, path, include_data=True, data_format=data_format ) return path # Alternative: if figure was created with fr.subplots() directly if hasattr(fig, "save_recipe"): fig.save_recipe(path, include_data=True, data_format=data_format) return path # Diagram figures: d.render() attaches _figrecipe_diagram if hasattr(fig, "_figrecipe_diagram"): from figrecipe._diagram._diagram._io import save_diagram_recipe save_diagram_recipe(fig._figrecipe_diagram, path) return path except Exception: pass # Recipe saving is optional return None def _capture_figure_state(fig, figure_record): """Capture current figure state into the record. This syncs the matplotlib figure state with the figrecipe record, ensuring the recipe reflects the final figure appearance. """ try: mpl_fig = fig._fig_mpl if hasattr(fig, "_fig_mpl") else fig # Update figure dimensions figsize = mpl_fig.get_size_inches() figure_record.figsize = list(figsize) figure_record.dpi = int(mpl_fig.dpi) # Capture style from scitex metadata if available if hasattr(mpl_fig, "_scitex_theme"): if not hasattr(figure_record, "style") or figure_record.style is None: figure_record.style = {} figure_record.style["theme"] = mpl_fig._scitex_theme except Exception: pass # Non-critical
[docs] def load_recipe( path: Union[str, Path], *, figrecipe_available: Optional[bool] = None, ) -> Any: """Load figrecipe recipe from FTS bundle. Parameters ---------- path : str or Path Path to bundle directory, zip file, or recipe.yaml. figrecipe_available : bool or None Whether figrecipe is importable. Defaults to the module-level ``FIGRECIPE_AVAILABLE`` flag; callers (and tests) may pass ``False`` to exercise the missing-dependency branch explicitly. Returns ------- tuple (fig, axes) reproduced from recipe. """ if figrecipe_available is None: figrecipe_available = FIGRECIPE_AVAILABLE if not figrecipe_available: raise ImportError("figrecipe is required for loading recipes") path = Path(path) # Handle bundle paths if path.is_dir(): recipe_path = path / "recipe.yaml" elif path.suffix == ".zip": # figrecipe can handle zip files directly recipe_path = path else: recipe_path = path return fr.reproduce(recipe_path)
[docs] def has_figrecipe() -> bool: """Check if figrecipe is available.""" return FIGRECIPE_AVAILABLE
# EOF