File: //home/arjun/projects/env/lib/python3.10/site-packages/sqlalchemy_serializer/serializer.py
import uuid
from datetime import datetime, date, time
from decimal import Decimal
from enum import Enum
import logging
import inspect
try:
# Python 3
from collections.abc import Iterable
except ImportError:
# Python 2.7
from collections import Iterable
from types import MethodType
from sqlalchemy import inspect as sql_inspect
from .lib.utils import get_type
from .lib.timezones import to_local_time, format_dt
from sqlalchemy_serializer.lib.schema import Schema
logger = logging.getLogger('serializer')
logger.setLevel(level="WARN")
class SerializerMixin(object):
"""
Mixin for retrieving public fields of sqlAlchemy-model in json-compatible format with no pain
Can be inherited to redefine get_tzinfo callback, datetime formats or to add some extra serialization logic
"""
# Default exclusive schema.
# If left blank, serializer becomes greedy and takes all SQLAlchemy-model's attributes
serialize_only = ()
# Additions to default schema. Can include negative rules
serialize_rules = ()
# Extra serialising functions
serialize_types = ()
date_format = '%Y-%m-%d'
datetime_format = '%Y-%m-%d %H:%M:%S'
time_format = '%H:%M'
decimal_format = '{}'
def get_tzinfo(self):
"""
Callback to make serializer aware of user's timezone. Should be redefined if needed
Example:
return pytz.timezone('Asia/Krasnoyarsk')
:return: datetime.tzinfo
"""
return None
@property
def serializable_keys(self):
"""
:return: set of keys available for serialization
"""
return {a.key for a in sql_inspect(self).mapper.attrs}
def to_dict(self, only=(), rules=(),
date_format=None, datetime_format=None, time_format=None, tzinfo=None,
decimal_format=None, serialize_types=None):
"""
Returns SQLAlchemy model's data in JSON compatible format
For details about datetime formats follow:
https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior
:param only: exclusive schema to replace default one (always have higher priority than rules)
:param rules: schema to extend default one or schema defined in "only"
:param date_format: str
:param datetime_format: str
:param time_format: str
:param decimal_format: str
:param serialize_types:
:param tzinfo: datetime.tzinfo converts datetimes to local user timezone
:return: data: dict
"""
s = Serializer(
date_format=date_format or self.date_format,
datetime_format=datetime_format or self.datetime_format,
time_format=time_format or self.time_format,
decimal_format=decimal_format or self.decimal_format,
tzinfo=tzinfo or self.get_tzinfo(),
serialize_types=serialize_types or self.serialize_types
)
return s(self, only=only, extend=rules)
class Serializer(object):
"""
All serialization logic is implemented here
"""
simple_types = (int, str, float, bool, type(None)) # Types that do nod need any serialization logic
complex_types = (Iterable, dict, SerializerMixin)
def __init__(self, **kwargs):
self.opts = kwargs
self.schema = None
def __call__(self, value, only=(), extend=()):
"""
Serialization starts here
:param value: Value to serialize
:param only: Exclusive schema of serialization
:param extend: Rules that extend default schema
:return: object: JSON-compatible object
"""
if not self.schema:
self.schema = Schema()
self.schema.update(only=only, extend=extend)
logger.debug('Call serializer for type:%s', get_type(value))
return self.serialize(value)
@staticmethod
def is_valid_callable(func) -> bool:
"""
Determines objects that should be called before serialization
"""
if callable(func):
i = inspect.getfullargspec(func)
if i.args == ['self'] and isinstance(func, MethodType) and not any([i.varargs, i.varkw]):
return True
return not any([i.args, i.varargs, i.varkw])
return False
def fork(self, value, key=None):
"""
Process data in a separate serializer
:return: serialized value
"""
if isinstance(value, self.simple_types) or not isinstance(value, self.complex_types):
return self.serialize(value)
serializer = Serializer(**self.opts)
if key is None:
serializer.schema = self.schema
else:
serializer.schema = self.schema.fork(key=key)
logger.debug('Fork serializer for type:%s', get_type(value))
return serializer(value)
def serialize(self, value):
if self.is_valid_callable(value):
value = value()
extra_serialization_types = self.opts.get('serialize_types', ())
serialize_types = (
*extra_serialization_types,
(self.simple_types, lambda x: x), # Should be checked before any other type
(bytes, lambda x: x.decode()),
(uuid.UUID, lambda x: str(x)),
(time, self.serialize_time), # Should be checked before datetime
(datetime, self.serialize_datetime),
(date, self.serialize_date),
(Decimal, self.serialize_decimal),
(dict, self.serialize_dict), # Should be checked before Iterable
(Iterable, self.serialize_iter),
(Enum, lambda x: value.value),
(SerializerMixin, self.serialize_model),
)
for types, callback in serialize_types:
if isinstance(value, types):
return callback(value)
raise IsNotSerializable(f'Unserializable type:{type(value)} value:{value}')
def serialize_datetime(self, value):
"""
datetime.datetime serialization logic
:param value:
:return: serialized value
"""
tz = self.opts.get('tzinfo')
if tz:
value = to_local_time(dt=value, tzinfo=tz)
return format_dt(
tpl=self.opts.get('datetime_format'),
dt=value
)
def serialize_date(self, value):
"""
datetime.date serialization logic
:param value:
:return: serialized value
"""
return format_dt(
tpl=self.opts.get('date_format'),
dt=value
)
def serialize_time(self, value):
"""
datetime.time serialization logic
:param value:
:return: serialized value
"""
return format_dt(
tpl=self.opts.get('time_format'),
dt=value
)
def serialize_decimal(self, value):
"""
decimal serialization logic
:param value:
:return: serialized value
"""
f = self.opts.get('decimal_format')
return f.format(value)
def serialize_iter(self, value):
"""
Serialization logic for any iterable object
:param value:
:return: list
"""
res = []
for v in value:
try:
res.append(self.fork(value=v))
except IsNotSerializable:
logger.warning('Can not serialize type:%s', get_type(v))
continue
return res
def serialize_dict(self, value):
"""
Serialization logic for any dict
:param value:
:return: dict
"""
res = {}
for k, v in value.items():
if self.schema.is_included(k): # TODO: Skip check if is NOT greedy
logger.debug('Serialize key:%s type:%s of dict', k, get_type(v))
res[k] = self.fork(key=k, value=v)
else:
logger.debug('Skip key:%s of dict', k)
return res
def serialize_model(self, value):
"""
Serialization logic for instances of SerializerMixin
:param value:
:return: dict
"""
self.schema.update(
only=value.serialize_only,
extend=value.serialize_rules
)
res = {}
keys = self.schema.keys
if self.schema.is_greedy:
keys.update(value.serializable_keys)
for k in keys:
if self.schema.is_included(key=k): # TODO: Skip check if is NOT greedy
v = getattr(value, k)
logger.debug('Serialize key:%s type:%s model:%s', k, get_type(v), get_type(value))
res[k] = self.fork(key=k, value=v)
else:
logger.debug('Skip key:%s of model:%s', k, get_type(value))
return res
class IsNotSerializable(Exception):
pass