"""Functions for parsing input arguments."""
import functools
import inspect
import re
import typing
import uuid
import pandas as pd
_T = typing.TypeVar("_T")
_R = typing.TypeVar("_R")
[docs]
def auto_parse(types: typing.Dict[str, typing.Type] = {}):
"""Function decorator to perform automatic parsing of input arguments.
Parameters
----------
types : dict of types, optional
The function's type annotations are used as defaults, which can be overridden by
specifying a dictionary of argument names together with their types.
"""
def decorator(fn):
signature = inspect.signature(fn)
keys = list(signature.parameters)
types_ = {k: v.annotation for k, v in signature.parameters.items()}
types_.update(types)
@functools.wraps(fn)
def fn_with_auto_parse(*args, **kwargs):
args = tuple(parse_type(v, types_[k]) for k, v in zip(keys, args))
kwargs = {k: parse_type(v, types_[k]) for k, v in kwargs.items()}
return fn(*args, **kwargs)
return fn_with_auto_parse
if callable(types):
fn = types
types = {}
return decorator(fn)
return decorator
[docs]
def parse_type(value: typing.Any, Type: _T) -> _T:
"""Parses a value given a specific type.
Parameters
----------
value : Any
The value to parse.
Type : T
The type used to decide how to parse the value.
Returns
-------
T
The parsed value.
Raises
------
ValueError
If the target type is ambiguous or the value cannot be parsed to the given type.
"""
if Type == "ignore" or Type == inspect._empty:
return value
if Type is bool:
return parse_bool(value)
if Type is int:
return parse_int(value)
if Type is str:
return parse_str(value)
if Type is uuid.UUID:
return parse_uuid(value)
if Type is pd.Timestamp:
return parse_time(value)
if hasattr(Type, "__annotations__") and isinstance(value, dict):
return {k: parse_type(v, Type.__annotations__[k]) for k, v in value.items()}
origin = typing.get_origin(Type)
args = typing.get_args(Type)
if origin == typing.Union:
# Handle optional type
try:
ActualType, MaybeNoneType = args
except ValueError:
pass
else:
if MaybeNoneType == type(None): # noqa: E721
return parse_optional(value, lambda x: parse_type(x, ActualType))
raise ValueError(f"Unable to parse value with multiple types '{Type}'")
if origin == typing.Literal:
if value in args:
return value
raise ValueError(f"Expected one of {args} but got '{value}'")
if origin is list:
try:
SubType = args[0]
except KeyError:
return list(value)
else:
return [parse_type(item, SubType) for item in value]
try:
return Type(value)
except TypeError:
raise ValueError(f"Failed to parse value '{value}' of type '{Type}'")
[docs]
def parse_bool(value: bool):
"""Parses boolean input values.
Parameters
----------
value : bool
The input value.
Returns
-------
bool
The input value.
Works as an identity function for valid input.
Raises
------
ValueError
If the input is not explicitly of boolean type.
"""
if not isinstance(value, bool):
raise ValueError("Boolean arguments must be either `True` or `False`")
return value
[docs]
def parse_int(value: int):
"""Parses integer input values.
Parameters
----------
value : int
The input value.
Returns
-------
int
The input value.
Works as an identity function for valid input.
Raises
------
ValueError
If the input is not explicitly of int type.
"""
if not isinstance(value, int):
raise ValueError(f"'{value}' is not of type int")
return value
[docs]
def parse_str(value: typing.Any):
"""Parses string input values.
Using this function prevents unintentional conversion of objects to strings.
Parameters
----------
value : Any
The input value.
Returns
-------
str
The input converted to a string.
Raises
------
ValueError
If the input cannot be safely convert to a string, such as lists.
"""
if type(value) not in (str, int, uuid.UUID):
raise ValueError(f"Attempted to convert '{value}' to string")
return str(value)
[docs]
def parse_optional(value: _T, parser: typing.Callable[[_T], _R]):
"""Applies parsing only if input is not None.
Parameters
----------
value : T or None
The input value to parse if not None.
parser : callable, of type T -> R
The parser to use if the input is not None.
Returns
-------
R or None
The result of applying the parser.
"""
return None if value is None else parser(value)
[docs]
def parse_time(value: typing.Union[str, int, pd.Timestamp]):
"""Parses input to a Timestamp object.
Parameters
----------
value : datetime-like
Can be anything parsable by pandas.
Integer is expected to be nanoseconds since UNIX epoch.
Returns
-------
Timestamp
The parsed input value.
Timezone is set to UTC if undefined.
"""
time = pd.Timestamp(value)
if time.tz is None:
return time.tz_localize("UTC")
return time
[docs]
def parse_list(value: typing.Any):
"""Parses input value to be of type list
Example {} -> [{}]
[{}] -> [{}]
Parameters
--------
value : Any
the input value
Returns
-------
list
the value wrapped within a list
"""
return [value] if not isinstance(value, list) else value
[docs]
def parse_uuid(value: typing.Any) -> str:
"""Parses strings expected to be UUIDs.
Parameters
----------
value : Any
The input value.
Returns
-------
str
The input value.
Works as an identity function for valid input.
Raises
------
ValueError
If the input value is not a valid UUID.
"""
return str(uuid.UUID(str(value)))
[docs]
def is_valid_uuid(value: typing.Any) -> bool:
"""Checks whether the given value is a UUID.
Parameters
----------
value : Any
The value to check.
Returns
-------
bool
True if the input is a valid UUID and false otherwise.
"""
try:
parse_uuid(value)
return True
except ValueError:
return False
[docs]
def to_snake_case(camelCase: str):
"""Converts camel case API naming conventions to snake case.
Parameters
----------
camelCase : str
A string written in camel case, e.g. 'profileId'.
Returns
-------
str
The string converted to snake case, e.g. 'profile_id'.
"""
return "_".join(re.findall("[A-Z]?[a-z]+", camelCase)).lower()
[docs]
def to_camel_case(snake_case: str):
"""Convert snake case fotonepy naming convention to camelCase
example: user_classifications -> userClassifications
Parameters
------------
snake_case : str
A string written in snake case, e.g. user_classifications
Returns
-----------
str
The 'snake_case' string converted to camelCase
"""
s = "".join([x.title() for x in snake_case.split("_")])
return s[:1].lower() if len(s) <= 1 else s[0].lower() + s[1:]