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 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 | None = fdict.get(name)
    # 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