Source code for pyiem.autoplot

"""Content from util for IEM Autoplot work."""

import re
from datetime import date, datetime, timedelta
from html import escape

from pyiem.exceptions import (
    BadWebRequest,
    IncompleteWebRequest,
    UnknownStationException,
)
from pyiem.network import Table as NetworkTable
from pyiem.reference import state_names

WFO_FOURCHAR = ["AFG", "GUM", "AFG", "HFO", "AFC", "AJK"]
DEFAULT_MINVAL = {"month": 1, "zhour": 0, "hour": 0, "day": 1}
DEFAULT_MAXVAL = {"month": 12, "zhour": 23, "hour": 23, "day": 31}


def _handle_date_err(exp, value, fmt):
    """Attempt to fix up a date string, when possible."""
    if "day" not in str(exp) and "range" not in str(exp):
        raise exp
    tokens = value.split(" ")
    datepart = tokens[0]
    (yyyy, mm, _dd) = datepart.split("-")
    # construct a new month date and then substract one day
    lastday = (date(int(yyyy), int(mm), 15) + timedelta(days=20)).replace(
        day=1
    ) - timedelta(days=1)
    # Reconstruct the date string
    res = lastday.strftime("%Y-%m-%d")
    if len(tokens) > 1:
        res += " " + tokens[1]
    # Careful here, we don't want a recursive loop
    return _strptime(res, fmt, rectify=False)


def _strptime(ins: str, fmt: str, rectify: bool = False) -> datetime:
    """Wrapper around strptime."""
    # Forgive an encoded space
    ins = ins.replace("+", " ")
    try:
        return datetime.strptime(ins, fmt)
    except ValueError as exp:
        if rectify:
            return _handle_date_err(exp, ins, fmt)
        raise IncompleteWebRequest(
            f"String provided: `{ins}` does not match format: `{fmt}`"
        ) from exp


def _float(val):
    """Convert string to float, if possible."""
    try:
        return float(val)
    except ValueError as exp:
        raise IncompleteWebRequest(f"Invalid float value: {val}") from exp


def _text_handler(value: str | None, pattern: str | None, default: str) -> str:
    """Handle text type with pattern."""
    if (
        value is not None
        and pattern is not None
        and not re.match(pattern, value)
    ):
        return default
    if value is None or value == "":
        return default
    return value


def _station_handler(
    value: str, opt: dict, name: str, fdict: dict, ctx: dict
) -> str:
    """Handle station."""
    # A bit of hackery here if we have a name ending in a number
    _n = name[-1] if name[-1] in ["1", "2", "3", "4", "5"] else ""
    netname = f"network{_n}"
    # The network variable tags along and within a non-PHP context,
    # this variable is unset, so we do some more hackery here
    ctx[netname] = fdict.get(netname, opt.get("network"))
    # Convience we load up the network metadata
    ntname = f"_nt{_n}"

    ctx[ntname] = NetworkTable(ctx[netname], only_online=False)
    if not value.startswith("_") and value not in ctx[ntname].sts:
        # HACK for three/four char ugliness
        if ctx[netname] == "WFO" and value in WFO_FOURCHAR:
            value = f"P{value}"
        elif ctx[netname] == "WFO" and value in ["JSJ", "SJU"]:
            value = "TJSJ"
        else:
            raise UnknownStationException("Unknown station provided.")
    # A helper to remove downstream boilerplate
    sname = ctx[ntname].sts.get(value, {"name": f"(({value}))"})["name"]
    ctx[f"_sname{_n}"] = f"[{value}] {sname}"
    return value


def _cmap_handler(value: str, default: str) -> str:
    """Handle colormap."""
    # Ensure that our value is a valid colormap known to matplotlib
    import matplotlib

    if value not in matplotlib.colormaps:
        value = default
    return value


def _select_handler(value: str | None, opt: dict, default: str) -> str:
    """Handle select type options."""
    options = opt.get("options", {})
    # Allow for legacy variable aliases
    alias = opt.get("alias", {})
    # in case of multi, value could be a list
    if value is None:
        value = default
    elif isinstance(value, str):
        if value in alias:
            value = alias[value]
        if value not in options:
            value = default
        if opt.get("multiple"):
            value = [value]
    else:
        newvalue = []
        for subval in value:
            if subval in alias:
                subval = alias[subval]
            if subval in options:
                newvalue.append(subval)
        value = newvalue
    return value


def _datetime_handler(
    value: str | None,
    default: str | None,
    minval: str | None,
    maxval: str | None,
    **kwargs,
) -> tuple[str | None, str | None, str | None, str | None]:
    """Handle datetime type options."""
    # tricky here, php has YYYY/mm/dd and CGI has YYYY-mm-dd
    if value is not None and value.strip() == "":
        value = default
    if default is not None:
        default = _strptime(default, "%Y/%m/%d %H%M")
    if minval is not None:
        minval = _strptime(minval, "%Y/%m/%d %H%M")
    if maxval is not None:
        maxval = _strptime(maxval, "%Y/%m/%d %H%M")
    if value is not None:
        # A common problem is for the space to be missing
        if value.find(" ") == -1:
            if len(value) == 14:
                value = f"{value[:10]} {value[10:]}"
            else:
                value += " 0000"
        value = _strptime(
            value[:15].replace("/", "-"),
            "%Y-%m-%d %H%M",
            rectify=kwargs.get("rectify_dates", False),
        )
    return value, minval, maxval, default


def _sday_handler(
    value: str, default: str | None, minval: str | None, maxval: str | None
) -> tuple[str | None, str | None, str | None, str | None]:
    """Handle sday type options."""
    # supports legacy uris with yyyy-mm-dd, before migration to sday
    if default is not None:
        default = _strptime(f"2000{default}", "%Y%m%d").date()
    if minval is not None:
        minval = _strptime(f"2000{minval}", "%Y%m%d").date()
    if maxval is not None:
        maxval = _strptime(f"2000{maxval}", "%Y%m%d").date()
    if value is not None:
        if value.find("-") > -1:
            value = _strptime(value, "%Y-%m-%d").date()
        else:
            value = _strptime(f"2000{value}", "%Y%m%d").date()
    return value, minval, maxval, default


def _date_handler(
    value: str,
    default: str | None,
    minval: str | None,
    maxval: str | None,
    **kwargs,
) -> tuple[str | None, str | None, str | None, str | None]:
    # tricky here, php has YYYY/mm/dd and CGI has YYYY-mm-dd
    if default is not None:
        default = _strptime(default, "%Y/%m/%d").date()
    if minval is not None:
        minval = _strptime(minval, "%Y/%m/%d").date()
    if maxval is not None:
        maxval = _strptime(maxval, "%Y/%m/%d").date()
    if value is not None:
        value = _strptime(
            value,
            "%Y-%m-%d",
            rectify=kwargs.get("rectify_dates", False),
        ).date()
    return value, minval, maxval, default


def _vtec_ps_handler(
    name: str, default: str | None, optional: bool, fdict: dict, ctx: dict
):
    # VTEC phenomena and significance
    defaults = {}
    # Only set a default value when the field is not optional
    if default is not None and not optional:
        tokens = default.split(".")
        if len(tokens) == 2 and len(tokens[0]) == 2 and len(tokens[1]) == 1:
            defaults["phenomena"] = tokens[0]
            defaults["significance"] = tokens[1]
    for label in ["phenomena", "significance"]:
        label2 = label + name
        ctx[label2] = fdict.get(label2, defaults.get(label))
        # Prevent empty strings from being set
        if ctx[label2] is not None and ctx[label2] == "":
            ctx[label2] = defaults.get(label)


def _filtervar_handler(ctx: dict, fdict: dict, opt: dict):
    """Handle autoplot filtervar type."""
    valid_comparators = {"ge", "gt", "le", "lt", "eq", "ne"}
    param_name = opt.get("name")
    cgi_value = fdict.get(param_name)
    default_value = opt.get("default")
    if cgi_value not in opt.get("options", {}):
        cgi_value = default_value
    default_comp = opt.get("comp_default", "ge")
    if default_comp not in valid_comparators:
        default_comp = "ge"
    comp_value = fdict.get(f"{param_name}_comp", default_comp)
    if comp_value not in valid_comparators:
        comp_value = default_comp
    thres_value = _float(
        fdict.get(f"{param_name}_t", opt.get("t_default", 1.0))
    )
    # We should be ready now to write these into the context
    ctx[param_name] = cgi_value
    ctx[f"{param_name}_comp"] = comp_value
    ctx[f"{param_name}_t"] = thres_value


def _process_option(
    opt: dict, fdict: dict, ctx: dict, enforce_optional: bool, **kwargs
):
    """Process an option dictionary and update the context accordingly."""
    name = opt.get("name")
    default = opt.get("default")
    typ: str = opt.get("type")
    minval = opt.get("min", DEFAULT_MINVAL.get(typ))
    maxval = opt.get("max", DEFAULT_MAXVAL.get(typ))
    optional: bool = opt.get("optional", False)
    value: str | list[str] | None = fdict.get(name)
    # value needs to be either None or `str` type, anything else is a problem
    if (
        not opt.get("multiple", False)
        and value is not None
        and not isinstance(value, str)
    ):
        raise BadWebRequest(
            f"Invalid value for parameter: {name} of type: {type(value)}, "
            "expected a string."
        )
    # vtec_ps is special since we have special logic to get its value
    if (
        optional
        and typ != "vtec_ps"
        and (
            value is None
            or (enforce_optional and fdict.get(f"_opt_{name}") != "on")
        )
    ):
        return
    if typ == "vtec_ps":
        _vtec_ps_handler(name, default, optional, fdict, ctx)
        return
    if typ == "filtervar":
        _filtervar_handler(ctx, fdict, opt)
        return

    if typ == "text":
        value = _text_handler(value, opt.get("pattern"), default)
    elif typ in ["station", "zstation", "sid", "networkselect"]:
        value = _station_handler(value or default, opt, name, fdict, ctx)
    elif typ == "cmap":
        value = _cmap_handler(value or "", default)
    elif typ in ["int", "month", "zhour", "hour", "day", "year"]:
        if value is not None:
            value = int(_float(value))
        if default is not None:
            default = int(_float(default))
    elif typ == "float":
        if value is not None:
            value = _float(value)
        if default is not None:
            default = _float(default)
    elif typ == "state":
        if value is not None:
            value = value.upper()
        if value not in state_names and default is not None:
            value = default
    elif typ == "select":
        value = _select_handler(value, opt, default)
    elif typ == "datetime":
        value, minval, maxval, default = _datetime_handler(
            value, default, minval, maxval, **kwargs
        )
    elif typ == "sday":
        value, minval, maxval, default = _sday_handler(
            value, default, minval, maxval
        )

    elif typ == "date":
        value, minval, maxval, default = _date_handler(
            value, default, minval, maxval, **kwargs
        )
    elif typ == "dat":
        # Damage Assessment Toolkit
        ctx["datglobalid"] = fdict.get("datglobalid")
    # validation
    if minval is not None and value is not None and value < minval:
        value = default
    if maxval is not None and value is not None and value > maxval:
        value = default
    ctx[name] = value if value is not None else default


[docs] def get_autoplot_context( fdict: dict, cfg: dict, enforce_optional: bool = False, **kwargs ) -> dict: """Get the variables out of a dict of strings This helper for IEM autoplot gets values out of a dictionary of strings, as provided by CGI. It does some magic to get types right, defaults right and so on. The typical way this is called ctx = iemutils.get_context(fdict, get_description()) Args: fdict (dict): what was likely provided by `cgi.FieldStorage()` cfg (dict): autoplot value of get_description enforce_optional (bool,optional): Should the `optional` flag be enforced rectify_dates (bool,optional): Attempt to fix common date errors like June 31. Default `false`. Returns: dictionary of variable names and values, with proper types! """ ctx = {} # Check for DPI setting val = fdict.get("dpi") if val is not None: ctx["dpi"] = int(val) # Copy internal parameters, these are not specified by the autoplot cfg for key in filter(lambda x: x.startswith("_"), fdict.keys()): ctx[key] = escape(fdict[key]) # Check over autoplot provided arguments for opt in cfg.get("arguments", []): _process_option(opt, fdict, ctx, enforce_optional, **kwargs) # Ensure defaults are set, if they exist for key in cfg.get("defaults", {}): if key not in ctx: ctx[key] = cfg["defaults"][key] return ctx