from types import EllipsisType
from typing import TypeVar, Callable, Union, Literal, get_origin, get_args, Type, Any
__all__ = [
'literal_value_type',
'bool_type',
'try_int_type',
'try_float_type',
'int_tuple_type',
'str_tuple_type',
'float_tuple_type',
'tuple_type',
'list_type',
'union_type',
'dict_type',
'slice_type',
'literal_type',
]
T = TypeVar('T')
[docs]
def literal_value_type(arg: str) -> bool | int | float | str:
"""Parse a string into its literal Python value"""
if arg.upper() == 'TRUE':
return True
elif arg.upper() == 'FALSE':
return False
try:
return int(arg)
except ValueError:
pass
try:
return float(arg)
except ValueError:
pass
return arg
[docs]
def bool_type(value: str) -> bool:
"""Convert a string to a boolean.
:param value: the input string to evaluate
:return: False for ('-', '0', 'f', 'false', 'n', 'no', 'x'), True for ('', '+', '1', 't', 'true', 'yes', 'y')
:raises ValueError: if the string is not recognized as a boolean
"""
value = value.lower()
if value in ('-', '0', 'f', 'false', 'n', 'no', 'x'):
return False
elif value in ('+', '1', 't', 'true', 'yes', 'y'):
return True
else:
raise ValueError()
[docs]
def tuple_type(*value_type: Type[T] | Callable[[str], T] | EllipsisType):
"""Create a caster that splits a comma-separated string into a tuple of typed values.
:param value_type: converter functions for each tuple position; use EllipsisType to repeat last
:return: a function that converts a comma-separated string into a typed tuple
"""
try:
i = value_type.index(...)
except ValueError:
pass
else:
if i == 0 or i != len(value_type) - 1:
raise RuntimeError()
def _type(arg: str) -> tuple[T, ...]:
ret = []
remain: Callable[[str], T] | None = None
for i, a in enumerate(arg.split(',')):
if remain is not None:
ret.append(remain(a))
else:
t = value_type[i]
if t is ...:
remain = value_type[i - 1] # pyright: ignore[reportAssignmentType]
ret.append(remain(a)) # pyright: ignore[reportOptionalCall]
else:
ret.append(t(a)) # pyright: ignore[reportCallIssue]
return tuple(ret)
return _type
str_tuple_type = tuple_type(str, ...)
"""tuple[str, ...]"""
int_tuple_type = tuple_type(int, ...)
"""tuple[int, ...]"""
float_tuple_type = tuple_type(float, ...)
"""tuple[float, ...]"""
[docs]
def list_type(value_type: Type[T] | Callable[[str], T] = str, *, split=',', prepend: list[T] | None = None):
"""Caster which converts a delimited string into a list of typed values
:param value_type: function to convert each element (default: str)
:param split: delimiter character (default: ',')
:param prepend: list of values to prepend when string starts with '+' + split
:return: function that converts a delimited string into a list
"""
def _cast(arg: str) -> list[T]:
if arg.startswith(remove := ('+' + split)) and prepend is not None:
value = list(map(value_type, arg[len(remove):].split(split)))
return [*prepend, *value]
else:
return list(map(value_type, arg.split(split)))
return _cast
[docs]
def union_type(*t: Callable[[str], T]):
"""
Caster that tries multiple converters in order until one succeeds.
:param t: converter functions to attempt
:return: function that returns first successful conversion
:raises TypeError: if all converters fail
"""
none_type = type(None)
def _type(arg: str):
for _t in t:
if _t is not none_type:
try:
return _t(arg)
except (TypeError, ValueError):
pass
raise TypeError
return _type
[docs]
def dict_type(value_type: Callable[[str], T] = str, default: dict[str, T] | None = None):
"""Caster that accumulates key-value pairs from 'key:value' or 'key=value' strings.
:param value_type: function to convert values (default: str)
:param default: initial dict to populate (default: new dict)
:return: function that updates and returns the dict
"""
if default is None:
default = {}
def _type(arg: str) -> dict[str, T]:
if ':' in arg:
i = arg.index(':')
value = arg[i + 1:]
if value_type is not None:
value = value_type(value)
default[arg[:i]] = value
elif '=' in arg:
i = arg.index('=')
value = arg[i + 1:]
if value_type is not None:
value = value_type(value)
default[arg[:i]] = value
elif value_type is None:
default[arg] = None
else:
default[arg] = value_type("")
return default
return _type
[docs]
def slice_type(arg: str) -> slice:
"""Convert a 'start:end' string into a slice object.
:param arg: string in 'start:end' format
:return: slice(start, end)
:raises ValueError: if format is invalid or parts are not integers
"""
i = arg.index(':')
v1 = int(arg[:i])
v2 = int(arg[i + 1:])
return slice(v1, v2)
[docs]
def try_int_type(arg: str) -> Union[int, str, None]:
"""Attempt to convert a string to int, returning original or None on failure.
:param arg: the input string
:return: int if parsing succeeds, original string if fails, or None if empty"""
if len(arg) == 0:
return None
try:
return int(arg)
except ValueError:
return arg
[docs]
def try_float_type(arg: str) -> Union[float, str, None]:
"""Attempt to convert a string to float, returning original or None on failure.
:param arg: the input string
:return: float if parsing succeeds, original string if fails, or None if empty"""
if len(arg) == 0:
return None
try:
return float(arg)
except ValueError:
return arg
[docs]
class literal_type:
"""Caster enforcing membership in a set of string literals with optional prefix matching"""
[docs]
def __init__(self, candidate: Any = None, *,
complete: bool = False):
self.candidate: tuple[str, ...] | None = None
self.optional = False
self.complete = complete
if candidate is not None:
self.set_candidate(candidate)
[docs]
def set_candidate(self, candidate: Any, overwrite: bool = False):
if get_origin(candidate) is Literal:
candidate = get_args(candidate)
if overwrite or self.candidate is None:
self.optional = None in candidate
if self.optional:
candidate = [it for it in candidate if it is not None]
self.candidate = tuple(candidate)
def __call__(self, arg: str):
assert self.candidate is not None
if arg in self.candidate:
return arg
if not self.complete:
raise ValueError
match [it for it in self.candidate if it.startswith(arg)]:
case []:
raise ValueError()
case [match]:
return match
case possible:
raise ValueError(f'confused {possible}')
def __str__(self):
assert self.candidate is not None
return 'Literal' + ('*' if self.complete else '') + '[' + ', '.join(self.candidate) + ']'
__repr__ = __str__