Skip to content

Instantly share code, notes, and snippets.

@malcolmgreaves
Last active July 24, 2019 22:44
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save malcolmgreaves/d71ae1f09075812e54d8ec54a5613616 to your computer and use it in GitHub Desktop.
Save malcolmgreaves/d71ae1f09075812e54d8ec54a5613616 to your computer and use it in GitHub Desktop.
There and back again: a namedtuple's tale. This is a module for serializing and deserializing generic namedtuple instances.
"""
There and back again: a namedtuple's tale. This is a module for serializing and deserializing generic
namedtuple instances.
The `serialize` and `deserialize` functions allow one to turn any `namedtuple` into a dictionary and to go
from such a `dict` to an instance of that `namedtuple`. These functions also work over `Sequence` & `Dict`
types: recursively exploring their structures to `serialize` or `deserialize` as-appropriate.
An example showing how to use this module is below:
```
import json
from typing import Sequence
from namedtuple_fmt import serialize, deserialize
X = NamedTuple('X', [('msg',str)])
json_str="""{"msg": "This is the first message"}"""
first_msg = deserialize(json.loads(json_str), X)
print(first_msg.msg)
print(deserialize(serialize(first_msg)) == X("This is the first message"))
print(deserialize(json.loads(json.dumps(serialize(first_msg)))) == X("This is the first message"))
json_str="""[{"msg": "This is the first message"},{"msg": "This is the 2nd message"}]"""
messages = deserialize(json.loads(json_str), Sequence[X])
print(f"{len(messages)} messages")
print('\n'.join(map(lambda x: x.msg, messages))
```
"""
from typing import (Any,
Iterable,
Type,
Optional,
Tuple,
Sequence,
Dict)
def is_namedtuple(x: Any) -> bool:
"""Check to see if a value is either an instance of or type for a namedtuple.
This function evaluates to `True` in two cases. The first case is if the value is a `type`
and that type is a subtype of either `collections.namedtuple` or `typing.NamedTuple`. The
second case is when the input is not a `type`, but the input is an instance of a namedtuple
type. I.e. the input is an instantiated value of some `collections.namedtuple` or
`typing.NamedTuple`.
If neither of the above cases holds, then this function evaluates to `False`.
NOTE: This function was pulled from the following StackOverflow post:
https://stackoverflow.com/a/2166841
"""
if x is None:
return False
if isinstance(x, type):
t = x
else:
t = type(x)
b = t.__bases__
if len(b) != 1 or b[0] != tuple:
return False
if getattr(t, '_make', None) is None:
return False
f = getattr(t, '_fields', None)
if not isinstance(f, tuple):
return False
return all(type(n) == str for n in f)
def is_typed_namedtuple(x: Any) -> bool:
"""Check to see if a value is a `typing.NamedTuple` instance or `type`.
This function evaluates to `True` if the input is either a `type` that derives from
`typing.NamedTuple` or if the input is an instantiated `NamedTuple`. In all other cases
this function evaluates to `False`.
"""
return is_namedtuple(x) and getattr(x, '_field_types', None) is not None
def serialize(value: Any) -> Any:
"""Attempts to convert the `value` into an equivalent `dict` structure.
NOTE: If the value is not a namedtuple, dict, enum, or iterable, then the value is returned as-is.
"""
if is_namedtuple(value):
return {k: serialize(raw_val) for k, raw_val in value._asdict().items()}
elif isinstance(value, Dict):
return {serialize(k): serialize(v) for k, v in value.items()}
elif isinstance(value, Iterable) and not isinstance(value, str):
return list(map(serialize, value))
elif isinstance(value, Enum):
# serialize the enum value's name as it's a better identifier than the
# actual enum value, which is usually inconsequential and arbitrary
# additionally, the name will _always_ be a str, so we can easily
# serialize & deserialize it
return value.name
else:
return value
def deserialize(type_value: Type, value: Any) -> Any:
"""Does final conversion of the `dict`-like `value` into an instance of `type_value`.
NOTE: If the input type `type_value` is a sequence, then deserialization is attempted on each
element. If it is a `dict`, then deserialization is attempted on each key and value. If this
specified type is a namedtuple or enum, then it will be appropriately handled.
Values without these explicit types are returned as-is.
"""
if is_namedtuple(type_value):
return _namedtuple_from_dict(type_value, value)
elif isinstance(type_value, type) and issubclass(type_value, Dict):
k_type, v_type = type_value.__args__ # type: ignore
return {
deserialize(k_type, k): deserialize(v_type, v) for k, v in value.items()
}
elif (
isinstance(type_value, type)
and issubclass(type_value, Tuple) # type: ignore
and not issubclass(type_value, str)
):
tuple_type_args = type_value.__args__
converted = map(
lambda type_val_pair: deserialize(type_val_pair[0], type_val_pair[1]),
zip(tuple_type_args, value),
)
return tuple(converted)
elif (
isinstance(type_value, type)
and issubclass(type_value, Iterable)
and not issubclass(type_value, str)
):
i_type, = type_value.__args__ # type: ignore
converted = map(lambda x: deserialize(i_type, x), value)
if issubclass(type_value, Set):
return set(converted)
else:
return list(converted)
elif isinstance(type_value, type) and issubclass(type_value, Enum):
# instead of serializing the enum's _value_, we serialize it's _name_
# so we can obtain the actual _value_ by looking in the enum type's __dict__
# attribute with our supplied name
return type_value[value] # type: ignore
else:
return value
def _namedtuple_from_dict(namedtuple_type: Type, data: dict) -> Any:
"""Initializes an instance of the given namedtuple type from a dict of its names and values.
The `namedtuple_type` must be a valid namedtuple type object. The dictionary, `data`, must
contain keys that match 1-to-1 with the field names of the supplied namedtuple type. Moreover,
the values for these keys must be appropriate.
Raises
------
TypeError: If the type `namedtuple_type` is not a namedtuple type. Notability, this means
it must have field names accessible via `._fields` and a constructor, which
accepts a sequence of valid field types (in the field names order), accessible
via `._make`.
"""
if is_namedtuple(namedtuple_type):
try:
field_values = tuple(_values_for_namedtuple(namedtuple_type, data))
return namedtuple_type._make(field_values) # type: ignore
except AttributeError as ae:
raise TypeError("Did you pass in a valid NamedTuple type? It needs ._field_types "
"to return the list of valid field names & expected types! "
"And ._make to accept the initialization values.", ae)
else:
raise TypeError(f"Expecting a type derived from typing.NamedTuple or "
f"collections.namedtuple not a: {namedtuple_type}")
def _values_for_namedtuple(type_data: Type, data: dict) -> Iterable:
"""Instantiates a namedtuple instance from a dictionary of field names & their respective values.
"""
field_name_maybe_type = _field_name_optional_types(type_data)
for field_name, field_type in field_name_maybe_type:
value = data[field_name]
yield deserialize(type_value=field_type, value=value)
def _field_name_optional_types(namedtuple_type: Type) -> Sequence[Tuple[str, Optional[type]]]:
"""Obtains a list of (field_name, option(field_type)) pairs. The field_type is non-None iff
the input namedtuple type is a `typing.NamedTuple`. Otherwise this typing information isn't
present at runtime: this is indicated by a `None` value."""
if is_typed_namedtuple(namedtuple_type):
return list(namedtuple_type._field_types.items()) # type: ignore
else:
return list(map(lambda f: (f, None), namedtuple_type._fields)) # type: ignore
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment