Source code for mws.utils.params

"""Parameter manipulation utilities."""

import datetime
import json
from collections.abc import Iterable, Mapping
from enum import Enum
from typing import Any, List, Union
from urllib.parse import quote


def enumerate_param(param: str, values: Union[list, set, tuple]) -> dict:
    """Builds a dictionary of an enumerated parameter, using the param string and some values.
    If values is not a list, tuple, or set, it will be coerced to a list
    with a single item.

    Example:
        enumerate_param('MarketplaceIdList.Id', (123, 345, 4343))
    Returns:
        {
            MarketplaceIdList.Id.1: 123,
            MarketplaceIdList.Id.2: 345,
            MarketplaceIdList.Id.3: 4343
        }
    """
    if not isinstance(values, (list, set, tuple)):
        # Coerces a single value to a list before continuing.
        values = [values]
    if not any(values):
        # if not values -> returns ValueError
        return {}
    param = dot_appended_param(param)
    # Return final output: dict comprehension of the enumerated param and values.
    return {f"{param}{idx}": val for idx, val in enumerate(values, start=1)}


def enumerate_params(params: Mapping = None) -> dict:
    """For each param and values, runs enumerate_param,
    returning a flat dict of all results
    """
    if not params or not isinstance(params, Mapping):
        return {}
    params_output = {}
    for param, values in params.items():
        params_output.update(enumerate_param(param, values))
    return params_output


def enumerate_keyed_param(param: str, values: List[Mapping]) -> dict:
    """Given a param string and a list of values dicts, returns a flat dict of keyed, enumerated params.
    Each dict in the values list must pertain to a single item and its data points.

    Example:
        param = "InboundShipmentPlanRequestItems.member"
        values = [
            {'SellerSKU': 'Football2415',
            'Quantity': 3},
            {'SellerSKU': 'TeeballBall3251',
            'Quantity': 5},
            ...
        ]

    Returns:
        {
            'InboundShipmentPlanRequestItems.member.1.SellerSKU': 'Football2415',
            'InboundShipmentPlanRequestItems.member.1.Quantity': 3,
            'InboundShipmentPlanRequestItems.member.2.SellerSKU': 'TeeballBall3251',
            'InboundShipmentPlanRequestItems.member.2.Quantity': 5,
            ...
        }
    """
    if not isinstance(values, (list, set, tuple)):
        # If it's a single value, convert it to a list first
        values = [values]
    if not any(values):
        # Shortcut for empty values
        return {}

    param = dot_appended_param(param)
    for val in values:
        # Every value in the list must be a dict.
        if not isinstance(val, dict):
            # Value is not a dict: can't work on it here.
            raise ValueError(
                (
                    "Non-dict value detected. "
                    "`values` must be a list, tuple, or set; containing only dicts."
                )
            )
    params = {}
    for idx, val_dict in enumerate(values, start=1):
        # Build the final output.
        params.update({f"{param}{idx}.{k}": v for k, v in val_dict.items()})
    return params


def dict_keyed_param(param: str, dict_from: Mapping) -> dict:
    """Given a param string and a dict, returns a flat dict of keyed params without enumerate.

    Example:
        param = "ShipmentRequestDetails.PackageDimensions"
        dict_from = {'Length': 5, 'Width': 5, 'Height': 5, 'Unit': 'inches'}

    Returns:
        {
            'ShipmentRequestDetails.PackageDimensions.Length': 5,
            'ShipmentRequestDetails.PackageDimensions.Width': 5,
            'ShipmentRequestDetails.PackageDimensions.Height': 5,
            'ShipmentRequestDetails.PackageDimensions.Unit': 'inches',
            ...
        }
    """
    params = {}

    param = dot_appended_param(param)
    for k, v in dict_from.items():
        params.update({f"{param}{k}": v})
    return params


[docs]def flat_param_dict(value: Union[str, Mapping, List], prefix: str = "") -> dict: """Returns a flattened params dictionary by collapsing nested dicts and non-string iterables. Any arbitrarily-nested dict or iterable will be expanded and flattened. - Each key in a child dict will be concatenated to its parent key. - Elements of a non-string iterable will be enumerated using a 1-based index, with the index number concatenated to the parent key. - In both cases, keys and sub-keys are joined by ``.``. If ``prefix`` is set, all keys in the resulting output will begin with ``prefix + '.'``. """ prefix = "" if not prefix else str(prefix) # Prefix is now either an empty string or a valid prefix string ending in '.' # NOTE should ensure that a `None` value is changed to empty string, as well. if isinstance(value, str) or not isinstance(value, (Mapping, Iterable)): # Value is not one of the types we want to expand. if prefix: # Can return a single dict of the prefix and value as a base case prefix = dot_appended_param(prefix, reverse=True) return {prefix: value} raise ValueError( ( "Non-dict, non-iterable value requires a prefix " "(would return a mapping of `prefix: value`)" ) ) # Past here, the value is something that must be expanded. # We'll build that output with recursive calls to `flat_param_dict`. if prefix: prefix = dot_appended_param(prefix) output = {} if isinstance(value, Mapping): for key, val in value.items(): new_key = f"{prefix}{key}" output.update(flat_param_dict(val, prefix=new_key)) else: # value must be an Iterable for idx, val in enumerate(value, start=1): new_key = f"{prefix}{idx}" output.update(flat_param_dict(val, prefix=new_key)) return output
def dot_appended_param(param_key: str, reverse: bool = False): """Returns ``param_key`` string, ensuring that it ends with ``'.'``. Set ``reverse`` to ``True`` (default ``False``) to reverse this behavior, ensuring that ``param_key`` *does not* end with ``'.'``. """ if not param_key.endswith("."): # Ensure this enumerated param ends in '.' param_key += "." if reverse: # Since param_key is guaranteed to end with '.' by this point, # if `reverse` flag was set, now we just get rid of it. param_key = param_key[:-1] return param_key BOOL_FALSE_STRINGS = ("no", "n", "none", "off", "false", "0") def coerce_to_bool(val: Any) -> bool: """Coerces ``val`` to a boolean for use in MWS requests. If ``val`` is a string, converts certain (case-insensitive) string values to "False", such as: - "no" - "n" - "none" - "off" - "false" - "0" Otherwise, ``val`` is simply cast using built-in ``bool()`` function. """ if isinstance(val, str) and val.lower() in BOOL_FALSE_STRINGS: return False return bool(val) def remove_empty_param_keys(params: Mapping) -> dict: """Returns a copy of ``params`` dict where any key with a value of ``None`` or ``""`` (empty string) are removed. """ return {k: v for k, v in params.items() if v is not None and v != ""} def clean_params_dict(params: Mapping, urlencode=False) -> dict: """Clean multiple param values in a dict, returning a new dict containing the original keys and cleaned values. """ cleaned_params = dict() for key, val in params.items(): newval = clean_value(val) if urlencode: newval = quote(val, safe="-_.~") cleaned_params[key] = newval return cleaned_params def clean_value(val: Any) -> str: """Attempts to clean a value so that it can be sent in a request.""" if isinstance(val, (Mapping, list, set, tuple)): raise ValueError("Cannot clean parameter value of type %s" % str(type(val))) if isinstance(val, (datetime.datetime, datetime.date)): return clean_date(val) if isinstance(val, bool): return clean_bool(val) if isinstance(val, Enum): return clean_enum(val) # For all else, simply convert to a string value. return str(val) def clean_bool(val: bool) -> str: """Converts a boolean value to its JSON string equivalent.""" if val is not True and val is not False: raise ValueError("Expected a boolean, got %s" % val) return json.dumps(val) def clean_date(val: Union[datetime.datetime, datetime.date]) -> str: """Converts a datetime.datetime or datetime.date to ISO 8601 string. Further passes that string through `urllib.parse.quote`. """ return val.isoformat() def clean_enum(val: Union[Enum, str]) -> str: """Simply put, converts an Enum to its ``.value`` attribute. All known MWS Enum classes *should* return a proper value in this case. """ return val.value def iterable_param(val) -> Iterable: """Wraps single values (that are *not* non-string iterables) as a list. Used for methods that require some iterable value that should be enumerated (without exploding string values into enumerated lists of their characters). """ # Special case: strings are iterables, too, but shouldn't be treated the same. # TODO make the type check at the end more generic? if isinstance(val, str) or not isinstance(val, Iterable): return [val] return val