One of the big problems I have is cleanly converting from an internal datatype to an external datatype. We can all do it the not so clean way, however I think this add too much mess.
I can use libraries that read from a filetype to a Python object, however some libraries don't allow you to convert the data from one type to another. Most libraries also don't allow converting from one structure to another, so if you need data to be nested when it comes flat, you have to perform the conversion manually.
This library still uses these filetype libraries to convert to a Python object. It just offloads some of the work from these libraries. And adds some features I don't think will be added to these libraries.
This library consists of two public classes; Converter
and Converters
. These work almost completely independently. A short explanation of most of the code is:
Converters
defines someproperty
functions, these interface withConverter._obj
to convert to and from the base class.ron
is used to raise whenConverter._obj
returns a 'null' (ABuilderObject
), this is as we build aBuilderObject
before building the actual class. This allows you to initialize usingsetattr
, rather than just passing a dictionary. Which I find to be a little cleaner at times.
This should be used whenever you get data fromConverter._obj
.BuilderObject
is a simple object that defaults nested objects to itself. This means we can build nested datatypes without having to build the objects themselves - as we don't have the data.Converter
is a small unobtrusive class to convert to and from the base class and itself. ProvidingT
when using the class is required for the code to work.
from datetime import datetime
from typing import Generic, TypeVar, Type, get_type_hints, Union, List, Optional, Tuple, Any
__all__ = ['ron', 'Converter', 'Converters']
T = TypeVar('T')
class BuilderObject:
def __init__(self):
super().__setattr__('__values', {})
def __getattr__(self, name):
return super().__getattribute__('__values').setdefault(name, BuilderObject())
def __setattr__(self, name, value):
super().__getattribute__('__values')[name] = value
def __delattr__(self, name):
del super().__getattribute__('__values')[name]
def _build(base: Type[T], values: Union[BuilderObject, dict]) -> T:
"""Build the object recursively, utilizes the type hints to create the correct types"""
types = get_type_hints(base)
if isinstance(values, BuilderObject):
values = super(BuilderObject, values).__getattribute__('__values')
for name, value in values.items():
if isinstance(value, BuilderObject) and name in types:
values[name] = _build(types[name], value)
return base(**values)
def _get_args(obj: object, orig: Type) -> Optional[Tuple[Type]]:
"""Get args from obj, filtering by orig type"""
bases = getattr(type(obj), '__orig_bases__', [])
for b in bases:
if b.__origin__ is orig:
return b.__args__
return None
class Converter(Generic[T]):
_obj: T
def __init__(self, **kwargs) -> None:
self._obj = BuilderObject()
for name, value in kwargs.items():
setattr(self, name, value)
def build(self, exists_ok: bool=False) -> T:
"""Build base object"""
t = _get_args(self, Converter)
if t is None:
raise ValueError('No base')
base_cls = t[0]
if isinstance(self._obj, base_cls):
if not exists_ok:
raise TypeError('Base type has been built already.')
return self._obj
self._obj = _build(base_cls, self._obj)
return self._obj
@classmethod
def from_(cls, b: T):
"""Build function from base object"""
c = cls()
c._obj = b
return c
def ron(obj: T) -> T:
"""Error on null result"""
if isinstance(obj, BuilderObject):
raise AttributeError()
return obj
TPath = Union[str, List[str]]
class Converters:
@staticmethod
def _read_path(path: TPath) -> List[str]:
"""Convert from public path formats to internal one"""
if isinstance(path, list):
return path
return path.split('.')
@staticmethod
def _get(obj: Any, path: List[str]) -> Any:
"""Helper for nested `getattr`s"""
for segment in path:
obj = getattr(obj, segment)
return obj
@classmethod
def property(cls, path: TPath, *, get_fn=None, set_fn=None):
"""
Allows getting data to and from `path`.
You can convert/type check the data using `get_fn` and `set_fn`. Both take and return one value.
"""
p = ['_obj'] + cls._read_path(path)
def get(self):
value = ron(cls._get(self, p))
if get_fn is not None:
return get_fn(value)
return value
def set(self, value: Any) -> Any:
if set_fn is not None:
value = set_fn(value)
setattr(cls._get(self, p[:-1]), p[-1], value)
def delete(self: Any) -> Any:
delattr(cls._get(self, p[:-1]), p[-1])
return property(get, set, delete)
@classmethod
def date(cls, path: TPath, format: str):
"""Convert to and from the date format specified"""
def get_fn(value: datetime) -> str:
return value.strftime(format)
def set_fn(value: str) -> datetime:
return datetime.strptime(value, format)
return cls.property(path, get_fn=get_fn, set_fn=set_fn)
An example of using this code is:
from dataclasses import dataclass
from datetime import datetime
from converters import Converter, Converters
from dataclasses_json import dataclass_json
@dataclass
class Range:
start: datetime
end: datetime
@dataclass
class Base:
date: datetime
range: Range
@dataclass_json
@dataclass(init=False)
class International(Converter[Base]):
date: str = Converters.date('date', '%d/%m/%y %H:%M')
start: str = Converters.date('range.start', '%d/%m/%y %H:%M')
end: str = Converters.date('range.end', '%d/%m/%y %H:%M')
class American(Converter[Base]):
date: str = Converters.date('date', '%m/%d/%y %H:%M')
start: str = Converters.date('range.start', '%m/%d/%y %H:%M')
end: str = Converters.date('range.end', '%m/%d/%y %H:%M')
if __name__ == '__main__':
i = International.from_json('''{
"date": "14/02/19 12:00",
"start": "14/02/19 12:00",
"end": "14/02/19 12:00"
}''')
b = i.build()
a = American.from_(b)
FORMAT = '{1}:\n\tdate: {0.date}\n\tstart: {0.range.start}\n\tend: {0.range.end}'
FORMAT_C = '{1}:\n\tdate: {0.date}\n\tstart: {0.start}\n\tend: {0.end}'
print(FORMAT.format(b, 'b'))
print(FORMAT_C.format(a, 'a'))
print(FORMAT_C.format(i, 'i'))
print('\nupdate b.date')
b.date = datetime(2019, 2, 14, 12, 30)
print(FORMAT.format(b, 'b'))
print(FORMAT_C.format(a, 'a'))
print(FORMAT_C.format(i, 'i'))
print('\nupdate b.range.start')
b.range.start = datetime(2019, 2, 14, 13, 00)
print(FORMAT.format(b, 'b'))
print(FORMAT_C.format(a, 'a'))
print(FORMAT_C.format(i, 'i'))
print('\njson dump')
print(i.to_json())
From a code review I mostly want to focus on increasing the readability of the code. I also want to keep Converter
to contain all of the logic, whilst also being very transparent so that most libraries like dataclasses_json
work with it. I don't care about performance just yet.
Range
has a third attributestep: int
, how would you refer to it in your external classes?step: int = Converters.property('range.step')
? \$\endgroup\$Range
too. If you need to convert to int from str then you can usestep: int = Converters.property('range.step', get_fn=str, set_fn=int)
. \$\endgroup\$