This commit is contained in:
Iliyan Angelov
2025-12-01 06:50:10 +02:00
parent 91f51bc6fe
commit 62c1fe5951
4682 changed files with 544807 additions and 31208 deletions

View File

@@ -0,0 +1,226 @@
# 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')