Source code for figrecipe._spec_builders

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Build figrecipe spec dicts from parameter dicts.

This module converts query-style parameter dicts (from REST APIs, CLIs, etc.)
into figrecipe-compatible spec dicts. It is the single source of truth for
kind registries and spec construction logic.

Usage
-----
>>> from figrecipe import build_spec, build_spec_from_csv
>>> spec = build_spec({"kind": "line", "y": "1,2,3,4", "title": "Demo"})
>>> spec = build_spec_from_csv("/tmp/data.csv", {"kind": "scatter", "x_col": "x", "y_col": "y"})
"""

from pathlib import Path  # noqa: F401 — used in "Path" forward-ref annotation
from typing import Dict, List, Union

__all__ = [
    "XY_KINDS",
    "DATA_KINDS",
    "LABEL_KINDS",
    "MATRIX_KINDS",
    "ALL_KINDS",
    "KIND_ALIASES",
    "build_spec",
    "build_spec_from_csv",
]

# ---- Kind registries --------------------------------------------------------

XY_KINDS = {"line", "scatter", "step", "errorbar", "stem", "bar", "barh"}
DATA_KINDS = {"hist", "box", "boxplot", "violin", "violinplot"}
LABEL_KINDS = {"pie"}
MATRIX_KINDS = {"heatmap", "imshow"}

ALL_KINDS = XY_KINDS | DATA_KINDS | LABEL_KINDS | MATRIX_KINDS
KIND_ALIASES = {"box": "boxplot", "violin": "violinplot"}

_UNSUPPORTED_MSG = (
    "Use: line, scatter, bar, barh, hist, box, violin, pie, "
    "heatmap, step, errorbar, stem"
)


# ---- Parse helpers ----------------------------------------------------------


def _parse_floats(s: str) -> List[float]:
    return [float(x.strip()) for x in s.split(",") if x.strip()]


def _parse_strings(s: str) -> List[str]:
    return [x.strip() for x in s.split(",") if x.strip()]


def _try_parse_floats(s: str) -> list:
    try:
        return _parse_floats(s)
    except ValueError:
        return _parse_strings(s)


# ---- Internal builders ------------------------------------------------------


def _init_spec(params: dict) -> tuple:
    kind = params.get("kind", "").lower()
    if not kind:
        raise ValueError("'kind' parameter is required")

    plot_kind = KIND_ALIASES.get(kind, kind)

    figure: Dict = {"style": "SCITEX"}
    if "width" in params:
        figure["width_mm"] = int(params["width"])
    if "height" in params:
        figure["height_mm"] = int(params["height"])

    spec: Dict = {"figure": figure, "plots": []}
    plot_entry: Dict = {"type": plot_kind}

    return spec, plot_entry, kind, plot_kind


def _finalize_spec(spec: dict, plot_entry: dict, params: dict) -> dict:
    color = params.get("color", "")
    if color:
        plot_entry["color"] = color

    spec["plots"].append(plot_entry)

    for key in ("title", "xlabel", "ylabel"):
        val = params.get(key, "")
        if val:
            spec[key] = val

    return spec


def _validate_kind(kind: str) -> None:
    if kind not in ALL_KINDS and kind not in KIND_ALIASES:
        raise ValueError(f"Unsupported kind: '{kind}'. {_UNSUPPORTED_MSG}")


def _build_xy(spec, plot_entry, params, kind):
    y_str = params.get("y", "")
    if not y_str:
        raise ValueError(f"'y' parameter is required for kind={kind}")
    plot_entry["y"] = _parse_floats(y_str)

    x_str = params.get("x", "")
    if x_str:
        x_vals = _try_parse_floats(x_str)
        if isinstance(x_vals[0], str):
            spec["xticks"] = {
                "positions": list(range(len(x_vals))),
                "labels": x_vals,
            }
            plot_entry["x"] = list(range(len(x_vals)))
        else:
            plot_entry["x"] = x_vals

    yerr_str = params.get("yerr", "")
    if yerr_str:
        plot_entry["yerr"] = _parse_floats(yerr_str)
        if kind != "errorbar":
            plot_entry["type"] = "errorbar"


def _build_distribution(spec, plot_entry, params, kind, plot_kind):
    groups = []
    group_labels = []

    data_str = params.get("data", "")
    if not data_str:
        raise ValueError(f"'data' parameter is required for kind={kind}")
    groups.append(_parse_floats(data_str))
    group_labels.append("Group 1")

    for i in range(2, 7):
        extra = params.get(f"data{i}", "")
        if extra:
            groups.append(_parse_floats(extra))
            group_labels.append(f"Group {i}")

    labels_str = params.get("labels", "")
    if labels_str:
        group_labels = _parse_strings(labels_str)

    if plot_kind in ("boxplot", "violinplot"):
        plot_entry["data"] = groups
        plot_entry["positions"] = list(range(len(groups)))
        spec["xticks"] = {
            "positions": list(range(len(groups))),
            "labels": group_labels[: len(groups)],
        }
    else:
        if len(groups) == 1:
            plot_entry["x"] = groups[0]
        else:
            plot_entry["x"] = groups


def _build_pie(plot_entry, params):
    data_str = params.get("data", "")
    if not data_str:
        raise ValueError("'data' parameter is required for kind=pie")
    plot_entry["x"] = _parse_floats(data_str)

    labels_str = params.get("labels", "")
    if labels_str:
        plot_entry["labels"] = _parse_strings(labels_str)


def _build_matrix(plot_entry, params, kind):
    data_str = params.get("data", "")
    if not data_str:
        raise ValueError(f"'data' parameter is required for kind={kind}")
    flat = _parse_floats(data_str)
    nrows = int(params.get("nrows", 0))
    ncols = int(params.get("ncols", 0))

    import numpy as np

    if nrows and ncols:
        if len(flat) != nrows * ncols:
            raise ValueError(
                f"data length {len(flat)} != nrows*ncols ({nrows}*{ncols})"
            )
        matrix = np.array(flat).reshape(nrows, ncols).tolist()
    else:
        n = len(flat)
        side = int(np.ceil(np.sqrt(n)))
        padded = flat + [0] * (side * side - n)
        matrix = np.array(padded).reshape(side, side).tolist()

    plot_entry["data"] = matrix
    plot_entry["type"] = "imshow"


# ---- Public API -------------------------------------------------------------


[docs] def build_spec(params: dict) -> dict: """Convert a parameter dict to a figrecipe spec dict. Parameters ---------- params : dict Flat parameter dict. Required key: ``kind``. For XY plots: ``x``, ``y`` (comma-separated floats or strings). For distributions: ``data``, ``data2`` .. ``data6``, ``labels``. For pie: ``data``, ``labels``. For heatmap: ``data``, ``nrows``, ``ncols``. Optional: ``color``, ``title``, ``xlabel``, ``ylabel``, ``width``, ``height``, ``yerr``. Returns ------- dict Figrecipe-compatible spec. Raises ------ ValueError If required parameters are missing or kind is unsupported. """ spec, plot_entry, kind, plot_kind = _init_spec(params) if kind in XY_KINDS: _build_xy(spec, plot_entry, params, kind) elif kind in DATA_KINDS or plot_kind in DATA_KINDS: _build_distribution(spec, plot_entry, params, kind, plot_kind) elif kind in LABEL_KINDS: _build_pie(plot_entry, params) elif kind in MATRIX_KINDS: _build_matrix(plot_entry, params, kind) else: _validate_kind(kind) return _finalize_spec(spec, plot_entry, params)
[docs] def build_spec_from_csv( csv_path: Union[str, "Path"], params: dict, ) -> dict: """Build a figrecipe spec from a CSV file path and parameters. Uses figrecipe's native ``data_file`` + column name resolution. Parameters ---------- csv_path : str or Path Path to the CSV file. params : dict Flat parameter dict. Required: ``kind``. For XY: ``x_col``, ``y_col``. For distributions: ``data_col``. For pie: ``data_col``, ``labels_col``. For heatmap: ``data_col``. Returns ------- dict Figrecipe-compatible spec. Raises ------ ValueError If required parameters are missing or kind is unsupported. """ spec, plot_entry, kind, plot_kind = _init_spec(params) plot_entry["data_file"] = str(csv_path) if kind in XY_KINDS: y_col = params.get("y_col", "") if not y_col: raise ValueError("'y_col' is required for XY plot kinds") plot_entry["y"] = y_col x_col = params.get("x_col", "") if x_col: plot_entry["x"] = x_col elif kind in DATA_KINDS or plot_kind in DATA_KINDS: data_col = params.get("data_col", "") if not data_col: raise ValueError("'data_col' is required for distribution plots") plot_entry["x"] = data_col elif kind in LABEL_KINDS: data_col = params.get("data_col", "") if not data_col: raise ValueError("'data_col' is required for pie charts") plot_entry["x"] = data_col labels_col = params.get("labels_col", "") if labels_col: plot_entry["labels"] = labels_col elif kind in MATRIX_KINDS: data_col = params.get("data_col", "") if not data_col: raise ValueError("'data_col' is required for heatmaps") plot_entry["data"] = data_col plot_entry["type"] = "imshow" else: _validate_kind(kind) return _finalize_spec(spec, plot_entry, params)
# EOF