# This file is part of py-serializable # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) Paul Horton. All Rights Reserved. from datetime import date, datetime from logging import getLogger from re import compile as re_compile from typing import TYPE_CHECKING, Any, Optional, Type, TypeVar, Union if TYPE_CHECKING: # pragma: no cover from xml.etree.ElementTree import Element from . import ObjectMetadataLibrary, ViewType _T = TypeVar('_T') _logger = getLogger(__name__) class BaseHelper: """Base Helper. Inherit from this class and implement/override the needed functions! This class does not provide any functionality, it is more like a Protocol with some fallback implementations. """ # region general/fallback @classmethod def serialize(cls, o: Any) -> Union[Any, str]: """general purpose serializer""" raise NotImplementedError() @classmethod def deserialize(cls, o: Any) -> Any: """general purpose deserializer""" raise NotImplementedError() # endregion general/fallback # region json specific @classmethod def json_normalize(cls, o: Any, *, view: Optional[Type['ViewType']], prop_info: 'ObjectMetadataLibrary.SerializableProperty', ctx: Type[Any], **kwargs: Any) -> Optional[Any]: """json specific normalizer""" return cls.json_serialize(o) @classmethod def json_serialize(cls, o: Any) -> Union[str, Any]: """json specific serializer""" return cls.serialize(o) @classmethod def json_denormalize(cls, o: Any, *, prop_info: 'ObjectMetadataLibrary.SerializableProperty', ctx: Type[Any], **kwargs: Any) -> Any: """json specific denormalizer :param tCls: the class that was desired to denormalize to :param pCls: tha prent class - as context """ return cls.json_deserialize(o) @classmethod def json_deserialize(cls, o: Any) -> Any: """json specific deserializer""" return cls.deserialize(o) # endregion json specific # region xml specific @classmethod def xml_normalize(cls, o: Any, *, element_name: str, view: Optional[Type['ViewType']], xmlns: Optional[str], prop_info: 'ObjectMetadataLibrary.SerializableProperty', ctx: Type[Any], **kwargs: Any) -> Optional[Union['Element', Any]]: """xml specific normalizer""" return cls.xml_serialize(o) @classmethod def xml_serialize(cls, o: Any) -> Union[str, Any]: """xml specific serializer""" return cls.serialize(o) @classmethod def xml_denormalize(cls, o: 'Element', *, default_ns: Optional[str], prop_info: 'ObjectMetadataLibrary.SerializableProperty', ctx: Type[Any], **kwargs: Any) -> Any: """xml specific denormalizer""" return cls.xml_deserialize(o.text) @classmethod def xml_deserialize(cls, o: Union[str, Any]) -> Any: """xml specific deserializer""" return cls.deserialize(o) # endregion xml specific class Iso8601Date(BaseHelper): _PATTERN_DATE = '%Y-%m-%d' @classmethod def serialize(cls, o: Any) -> str: if isinstance(o, date): return o.strftime(Iso8601Date._PATTERN_DATE) raise ValueError(f'Attempt to serialize a non-date: {o.__class__}') @classmethod def deserialize(cls, o: Any) -> date: try: return date.fromisoformat(str(o)) except ValueError: raise ValueError(f'Date string supplied ({o}) does not match either "{Iso8601Date._PATTERN_DATE}"') class XsdDate(BaseHelper): @classmethod def serialize(cls, o: Any) -> str: if isinstance(o, date): return o.isoformat() raise ValueError(f'Attempt to serialize a non-date: {o.__class__}') @classmethod def deserialize(cls, o: Any) -> date: try: v = str(o) if v.startswith('-'): # Remove any leading hyphen v = v[1:] if v.endswith('Z'): v = v[:-1] _logger.warning( 'Potential data loss will occur: dates with timezones not supported in Python', stacklevel=2) if '+' in v: v = v[:v.index('+')] _logger.warning( 'Potential data loss will occur: dates with timezones not supported in Python', stacklevel=2) return date.fromisoformat(v) except ValueError: raise ValueError(f'Date string supplied ({o}) is not a supported ISO Format') class XsdDateTime(BaseHelper): @staticmethod def __fix_tz(dt: datetime) -> datetime: """ Fix for Python's violation of ISO8601: :py:meth:`datetime.isoformat()` might omit the time offset when in doubt, but the ISO-8601 assumes local time zone. Anyway, the time offset is mandatory for this purpose. """ return dt.astimezone() \ if dt.tzinfo is None \ else dt @classmethod def serialize(cls, o: Any) -> str: if isinstance(o, datetime): return cls.__fix_tz(o).isoformat() raise ValueError(f'Attempt to serialize a non-date: {o.__class__}') # region fixup_microseconds # see https://github.com/madpah/serializable/pull/138 __PATTERN_FRACTION = re_compile(r'\.\d+') @classmethod def __fix_microseconds(cls, v: str) -> str: """ Fix for Python's violation of ISO8601 for :py:meth:`datetime.fromisoformat`. 1. Ensure either 0 or exactly 6 decimal places for seconds. Background: py<3.11 supports either 6 or 0 digits for milliseconds when parsing. 2. Ensure correct rounding of microseconds on the 6th digit. """ return cls.__PATTERN_FRACTION.sub(lambda m: f'{(float(m.group(0))):.6f}'[1:], v) # endregion fixup_microseconds @classmethod def deserialize(cls, o: Any) -> datetime: try: v = str(o) if v.startswith('-'): # Remove any leading hyphen v = v[1:] if v.endswith('Z'): # Replace ZULU time with 00:00 offset v = f'{v[:-1]}+00:00' return datetime.fromisoformat( cls.__fix_microseconds(v)) except ValueError: raise ValueError(f'Date-Time string supplied ({o}) is not a supported ISO Format')