Added bytes, datetime, and firestore format support to dataclassio

This commit is contained in:
Eric Froemling 2021-05-25 12:14:42 -05:00
parent 157be291f8
commit 7f1458304d
No known key found for this signature in database
GPG Key ID: 89C93F0F8D6D5A98
7 changed files with 214 additions and 86 deletions

View File

@ -3932,26 +3932,26 @@
"assets/build/windows/Win32/ucrtbased.dll": "https://files.ballistica.net/cache/ba1/b5/85/f8b6d0558ddb87267f34254b1450", "assets/build/windows/Win32/ucrtbased.dll": "https://files.ballistica.net/cache/ba1/b5/85/f8b6d0558ddb87267f34254b1450",
"assets/build/windows/Win32/vc_redist.x86.exe": "https://files.ballistica.net/cache/ba1/1c/e1/4a1a2eddda2f4aebd5f8b64ab08e", "assets/build/windows/Win32/vc_redist.x86.exe": "https://files.ballistica.net/cache/ba1/1c/e1/4a1a2eddda2f4aebd5f8b64ab08e",
"assets/build/windows/Win32/vcruntime140d.dll": "https://files.ballistica.net/cache/ba1/50/8d/bc2600ac9491f1b14d659709451f", "assets/build/windows/Win32/vcruntime140d.dll": "https://files.ballistica.net/cache/ba1/50/8d/bc2600ac9491f1b14d659709451f",
"build/prefab/full/linux_arm64/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/2b/e1/628191b4c0b260036e0d7abc6ee3", "build/prefab/full/linux_arm64/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/93/85/ceb5bcda0a0a8b9cde91e599f505",
"build/prefab/full/linux_arm64/release/ballisticacore": "https://files.ballistica.net/cache/ba1/83/51/0511414b7d61f636b55bb6e63b00", "build/prefab/full/linux_arm64/release/ballisticacore": "https://files.ballistica.net/cache/ba1/76/38/49d10532844bfe7df52b53216d2b",
"build/prefab/full/linux_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/83/a1/3046fc4c86fb31655da6b2d076cb", "build/prefab/full/linux_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/28/8a/26ac9d06cc08010e5ef47fe1e14b",
"build/prefab/full/linux_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/ba/e7/d0c78aaf3c6982eee87471ff5637", "build/prefab/full/linux_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/b5/71/06235b13926864357c4b6cff841c",
"build/prefab/full/linux_x86_64/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/a3/43/5e2dd209bedf7f3c5550f416fb6e", "build/prefab/full/linux_x86_64/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/2e/c5/61ca27f1d369d2e11025c9b41c13",
"build/prefab/full/linux_x86_64/release/ballisticacore": "https://files.ballistica.net/cache/ba1/7b/e0/5c94fd10dfb247529548a76ddd75", "build/prefab/full/linux_x86_64/release/ballisticacore": "https://files.ballistica.net/cache/ba1/d9/58/78661f5aecd0da126b10201d0c5e",
"build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/13/54/1e3e4532fa176cca029fc448ac6e", "build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/36/be/d706c897e778fa1b0ba439eff8c1",
"build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/d9/5b/d8a9b7d2cda3eaff208944273b08", "build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/eb/15/84f0a16a78fee48970391300b957",
"build/prefab/full/mac_arm64/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/3b/37/1055f167fbab28003f7129e5aee6", "build/prefab/full/mac_arm64/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/60/eb/4f65457cf792f721bbad617d9ad0",
"build/prefab/full/mac_arm64/release/ballisticacore": "https://files.ballistica.net/cache/ba1/25/aa/6f6c4ef8ab7d39f46b8bf69fa18b", "build/prefab/full/mac_arm64/release/ballisticacore": "https://files.ballistica.net/cache/ba1/71/f1/4f36fb59372af667d2fcfc16c6d8",
"build/prefab/full/mac_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/f3/c4/619b03c16992b06d8162cdb96126", "build/prefab/full/mac_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/7f/e6/13d77d7939fa00e57f16ed440f92",
"build/prefab/full/mac_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/2f/1b/6ec4f564ea77d42a30917c8434c9", "build/prefab/full/mac_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/44/2d/1132879368551cc7bd7fb407f0fd",
"build/prefab/full/mac_x86_64/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/b5/73/194f8c35a467b775eebe24de90a2", "build/prefab/full/mac_x86_64/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/77/6e/d9920eeaa9b9adba4cb3f5e655f0",
"build/prefab/full/mac_x86_64/release/ballisticacore": "https://files.ballistica.net/cache/ba1/b0/a7/7848042fbcd5764af31020e53ae1", "build/prefab/full/mac_x86_64/release/ballisticacore": "https://files.ballistica.net/cache/ba1/b9/02/ed3c91a260a476a66baab752e75c",
"build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/28/a0/d2d53829a074e5c06cf5785af1cd", "build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/56/ca/d159a3a9dcb1f266413578651e16",
"build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/4d/53/3027bbbab3c19fa2b8d0fa718d96", "build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/c9/5e/9cc46cf58951c133917a04f04cd0",
"build/prefab/full/windows_x86/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/f9/c7/b04b34fcdc0e0e0f9976b5639c0c", "build/prefab/full/windows_x86/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/90/84/390b6916bbf2b6710729a923ab7c",
"build/prefab/full/windows_x86/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/87/a9/e295e82a1f3b423fb2f8f2eb462f", "build/prefab/full/windows_x86/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/3e/f8/36ae7dc2ac79f3838999f66e1c87",
"build/prefab/full/windows_x86_server/debug/dist/ballisticacore_headless.exe": "https://files.ballistica.net/cache/ba1/1c/bd/4afc4c92dcaf095928f66d5c61d3", "build/prefab/full/windows_x86_server/debug/dist/ballisticacore_headless.exe": "https://files.ballistica.net/cache/ba1/fe/a1/b217231f196dc175663d7b70b4a9",
"build/prefab/full/windows_x86_server/release/dist/ballisticacore_headless.exe": "https://files.ballistica.net/cache/ba1/ca/56/0923f65bb7c80211f614c4816ab5", "build/prefab/full/windows_x86_server/release/dist/ballisticacore_headless.exe": "https://files.ballistica.net/cache/ba1/95/7a/a961123e1b9bcd78a563759fd2cf",
"build/prefab/lib/linux_arm64/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/54/e0/8c89d241ca37ae32c9b46ea71456", "build/prefab/lib/linux_arm64/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/54/e0/8c89d241ca37ae32c9b46ea71456",
"build/prefab/lib/linux_arm64/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/e0/05/94ac67cbf2e665eeecfc77246caf", "build/prefab/lib/linux_arm64/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/e0/05/94ac67cbf2e665eeecfc77246caf",
"build/prefab/lib/linux_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/0f/e1/be897ef0e73b0e52c59f3396e0cd", "build/prefab/lib/linux_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/0f/e1/be897ef0e73b0e52c59f3396e0cd",
@ -3960,12 +3960,12 @@
"build/prefab/lib/linux_x86_64/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/1f/b3/80375870a9ab83bbb63b50b03a73", "build/prefab/lib/linux_x86_64/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/1f/b3/80375870a9ab83bbb63b50b03a73",
"build/prefab/lib/linux_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/5c/2e/a1b1f2126b3150c6f11e799c3805", "build/prefab/lib/linux_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/5c/2e/a1b1f2126b3150c6f11e799c3805",
"build/prefab/lib/linux_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/b6/6b/37174b3a0d72a05f1bb3d904b78b", "build/prefab/lib/linux_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/b6/6b/37174b3a0d72a05f1bb3d904b78b",
"build/prefab/lib/mac_arm64/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/43/b7/8f87aa950891250923c7cb617d32", "build/prefab/lib/mac_arm64/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/c4/c3/de4c713a14762b156cbc9f1dbacc",
"build/prefab/lib/mac_arm64/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/1a/e9/a0c9c846c00b5ed9645ddc70e44a", "build/prefab/lib/mac_arm64/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/e8/a5/7341744d46bf83683f6d9113019a",
"build/prefab/lib/mac_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/8a/e5/1f93415fb55842479f5ce0d375af", "build/prefab/lib/mac_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/3b/c7/76236792620e2f0a7a5551b32a45",
"build/prefab/lib/mac_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/e7/36/2475ad00f41cf75d63c786f3db39", "build/prefab/lib/mac_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/86/6d/2f7220ea5ab89b23ffacc7497694",
"build/prefab/lib/mac_x86_64/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/6c/52/ff241eeae0d43f433f66a8b2dcbb", "build/prefab/lib/mac_x86_64/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/9f/46/d0cd8a310edfa84f124d11a295d7",
"build/prefab/lib/mac_x86_64/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/40/a3/a2c2c741e551c40d0700888737a7", "build/prefab/lib/mac_x86_64/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/d7/7d/5196bbacb6efbfd4fe70cc042553",
"build/prefab/lib/mac_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/a1/ef/fa977c1c8a05781f6af8321b31f2", "build/prefab/lib/mac_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/73/63/38d1af71edf49e29f802533058a0",
"build/prefab/lib/mac_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/0d/15/336d51f7894aeedba54f5aabb04a" "build/prefab/lib/mac_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/d9/08/502699c51e7a36c1b60acec6be36"
} }

View File

@ -1088,6 +1088,7 @@
<w>intp</w> <w>intp</w>
<w>introspectable</w> <w>introspectable</w>
<w>intstr</w> <w>intstr</w>
<w>ioattrs</w>
<w>iobj</w> <w>iobj</w>
<w>iometa</w> <w>iometa</w>
<w>ioprep</w> <w>ioprep</w>

View File

@ -488,6 +488,7 @@
<w>interuptions</w> <w>interuptions</w>
<w>intstr</w> <w>intstr</w>
<w>invote</w> <w>invote</w>
<w>ioattrs</w>
<w>iobj</w> <w>iobj</w>
<w>iometa</w> <w>iometa</w>
<w>ioprep</w> <w>ioprep</w>

View File

@ -1,5 +1,5 @@
<!-- THIS FILE IS AUTO GENERATED; DO NOT EDIT BY HAND --> <!-- THIS FILE IS AUTO GENERATED; DO NOT EDIT BY HAND -->
<h4><em>last updated on 2021-05-24 for Ballistica version 1.6.3 build 20367</em></h4> <h4><em>last updated on 2021-05-25 for Ballistica version 1.6.4 build 20369</em></h4>
<p>This page documents the Python classes and functions in the 'ba' module, <p>This page documents the Python classes and functions in the 'ba' module,
which are the ones most relevant to modding in Ballistica. If you come across something you feel should be included here or could be better explained, please <a href="mailto:support@froemling.net">let me know</a>. Happy modding!</p> which are the ones most relevant to modding in Ballistica. If you come across something you feel should be included here or could be better explained, please <a href="mailto:support@froemling.net">let me know</a>. Happy modding!</p>
<hr> <hr>

View File

@ -21,8 +21,8 @@
namespace ballistica { namespace ballistica {
// These are set automatically via script; don't change here. // These are set automatically via script; don't change here.
const int kAppBuildNumber = 20368; const int kAppBuildNumber = 20369;
const char* kAppVersion = "1.6.3"; const char* kAppVersion = "1.6.4";
// Our standalone globals. // Our standalone globals.
// These are separated out for easy access. // These are separated out for easy access.

View File

@ -15,7 +15,7 @@ import pytest
from efro.util import utc_now from efro.util import utc_now
from efro.dataclassio import (dataclass_validate, dataclass_from_dict, from efro.dataclassio import (dataclass_validate, dataclass_from_dict,
dataclass_to_dict, ioprepped, IOMeta) dataclass_to_dict, ioprepped, IOAttrs, Codec)
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@ -461,13 +461,13 @@ def test_extra_data() -> None:
assert 'nonexistent' not in out assert 'nonexistent' not in out
def test_meta() -> None: def test_ioattrs() -> None:
"""Testing iometa annotations.""" """Testing ioattrs annotations."""
@ioprepped @ioprepped
@dataclass @dataclass
class _TestClass: class _TestClass:
dval: Annotated[Dict, IOMeta('d')] dval: Annotated[Dict, IOAttrs('d')]
obj = _TestClass(dval={'foo': 'bar'}) obj = _TestClass(dval={'foo': 'bar'})
@ -481,14 +481,14 @@ def test_meta() -> None:
@ioprepped @ioprepped
@dataclass @dataclass
class _TestClass2: class _TestClass2:
dval: Annotated[Dict, IOMeta('d', store_default=False)] dval: Annotated[Dict, IOAttrs('d', store_default=False)]
@ioprepped @ioprepped
@dataclass @dataclass
class _TestClass3: class _TestClass3:
dval: Annotated[Dict, IOMeta('d', store_default=False)] = field( dval: Annotated[Dict, IOAttrs('d', store_default=False)] = field(
default_factory=dict) default_factory=dict)
ival: Annotated[int, IOMeta('i', store_default=False)] = 123 ival: Annotated[int, IOAttrs('i', store_default=False)] = 123
# Both attrs are default; should get stripped out. # Both attrs are default; should get stripped out.
obj3 = _TestClass3() obj3 = _TestClass3()
@ -513,6 +513,51 @@ def test_meta() -> None:
assert obj3.ival == 125 assert obj3.ival == 125
def test_codecs() -> None:
"""Test differences with codecs."""
@ioprepped
@dataclass
class _TestClass:
bval: bytes
# bytes to/from JSON (goes through base64)
obj = _TestClass(bval=b'foo')
out = dataclass_to_dict(obj, codec=Codec.JSON)
assert isinstance(out['bval'], str) and out['bval'] == 'Zm9v'
obj = dataclass_from_dict(_TestClass, out, codec=Codec.JSON)
assert obj.bval == b'foo'
# bytes to/from FIRESTORE (passed as-is)
obj = _TestClass(bval=b'foo')
out = dataclass_to_dict(obj, codec=Codec.FIRESTORE)
assert isinstance(out['bval'], bytes) and out['bval'] == b'foo'
obj = dataclass_from_dict(_TestClass, out, codec=Codec.FIRESTORE)
assert obj.bval == b'foo'
now = utc_now()
@ioprepped
@dataclass
class _TestClass2:
dval: datetime.datetime
# datetime to/from JSON (turns into a list of values)
obj2 = _TestClass2(dval=now)
out = dataclass_to_dict(obj2, codec=Codec.JSON)
assert (isinstance(out['dval'], list) and len(out['dval']) == 7
and all(isinstance(val, int) for val in out['dval']))
obj2 = dataclass_from_dict(_TestClass2, out, codec=Codec.JSON)
assert obj2.dval == now
# datetime to/from FIRESTORE (passed through as-is)
obj2 = _TestClass2(dval=now)
out = dataclass_to_dict(obj2, codec=Codec.FIRESTORE)
assert isinstance(out['dval'], datetime.datetime)
obj2 = dataclass_from_dict(_TestClass2, out, codec=Codec.FIRESTORE)
assert obj2.dval == now
def test_dict() -> None: def test_dict() -> None:
"""Test various dict related bits.""" """Test various dict related bits."""

View File

@ -57,15 +57,21 @@ PREP_ATTR = '_DCIOPREP'
EXTRA_ATTRS_ATTR = '_DCIOEXATTRS' EXTRA_ATTRS_ATTR = '_DCIOEXATTRS'
class IOMeta: class Codec(Enum):
"""Metadata for specifying io behavior.""" """Influences format used for input/output."""
JSON = 'json'
FIRESTORE = 'firestore'
class IOAttrs:
"""For specifying io behavior in annotations."""
def __init__(self, storagename: str = None, store_default: bool = True): def __init__(self, storagename: str = None, store_default: bool = True):
self.storagename = storagename self.storagename = storagename
self.store_default = store_default self.store_default = store_default
def validate_for_field(self, cls: Type, field: dataclasses.Field) -> None: def validate_for_field(self, cls: Type, field: dataclasses.Field) -> None:
"""Ensure the IOMeta instance is ok to use with the provided field.""" """Ensure the IOAttrs instance is ok to use with the provided field."""
# Turning off store_default requires the field to have either # Turning off store_default requires the field to have either
# a default_factory or a default # a default_factory or a default
@ -78,7 +84,9 @@ class IOMeta:
f' store_default=False cannot be set for it.') f' store_default=False cannot be set for it.')
def dataclass_to_dict(obj: Any, coerce_to_float: bool = True) -> dict: def dataclass_to_dict(obj: Any,
codec: Codec = Codec.JSON,
coerce_to_float: bool = True) -> dict:
"""Given a dataclass object, return a json-friendly dict. """Given a dataclass object, return a json-friendly dict.
All values will be checked to ensure they match the types specified All values will be checked to ensure they match the types specified
@ -95,7 +103,10 @@ def dataclass_to_dict(obj: Any, coerce_to_float: bool = True) -> dict:
will be triggered. will be triggered.
""" """
out = _Outputter(obj, create=True, coerce_to_float=coerce_to_float).run() out = _Outputter(obj,
create=True,
codec=codec,
coerce_to_float=coerce_to_float).run()
assert isinstance(out, dict) assert isinstance(out, dict)
return out return out
@ -107,21 +118,24 @@ def dataclass_to_json(obj: Any, coerce_to_float: bool = True) -> str:
""" """
import json import json
return json.dumps( return json.dumps(
dataclass_to_dict(obj=obj, coerce_to_float=coerce_to_float), dataclass_to_dict(obj=obj,
coerce_to_float=coerce_to_float,
codec=Codec.JSON),
separators=(',', ':'), separators=(',', ':'),
) )
def dataclass_from_dict(cls: Type[T], def dataclass_from_dict(cls: Type[T],
values: dict, values: dict,
codec: Codec = Codec.JSON,
coerce_to_float: bool = True, coerce_to_float: bool = True,
allow_unknown_attrs: bool = True, allow_unknown_attrs: bool = True,
discard_unknown_attrs: bool = False) -> T: discard_unknown_attrs: bool = False) -> T:
"""Given a dict, return a dataclass of a given type. """Given a dict, return a dataclass of a given type.
The dict must be in the json-friendly format as emitted from The dict must be formatted to match the specified codec (generally
dataclass_to_dict. This means that sequence values such as tuples or json-friendly object types). This means that sequence values such as
sets should be passed as lists, enums should be passed as their tuples or sets should be passed as lists, enums should be passed as their
associated values, nested dataclasses should be passed as dicts, etc. associated values, nested dataclasses should be passed as dicts, etc.
All values are checked to ensure their types/values are valid. All values are checked to ensure their types/values are valid.
@ -141,6 +155,7 @@ def dataclass_from_dict(cls: Type[T],
case they will simply be discarded. case they will simply be discarded.
""" """
return _Inputter(cls, return _Inputter(cls,
codec=codec,
coerce_to_float=coerce_to_float, coerce_to_float=coerce_to_float,
allow_unknown_attrs=allow_unknown_attrs, allow_unknown_attrs=allow_unknown_attrs,
discard_unknown_attrs=discard_unknown_attrs).run(values) discard_unknown_attrs=discard_unknown_attrs).run(values)
@ -163,12 +178,15 @@ def dataclass_from_json(cls: Type[T],
discard_unknown_attrs=discard_unknown_attrs) discard_unknown_attrs=discard_unknown_attrs)
def dataclass_validate(obj: Any, coerce_to_float: bool = True) -> None: def dataclass_validate(obj: Any,
coerce_to_float: bool = True,
codec: Codec = Codec.JSON) -> None:
"""Ensure that values in a dataclass instance are the correct types.""" """Ensure that values in a dataclass instance are the correct types."""
# Simply run an output pass but tell it not to generate data; # Simply run an output pass but tell it not to generate data;
# only run validation. # only run validation.
_Outputter(obj, create=False, coerce_to_float=coerce_to_float).run() _Outputter(obj, create=False, codec=codec,
coerce_to_float=coerce_to_float).run()
def ioprep(cls: Type) -> None: def ioprep(cls: Type) -> None:
@ -186,10 +204,7 @@ def ioprep(cls: Type) -> None:
Prepping a dataclass involves evaluating its type annotations, which, Prepping a dataclass involves evaluating its type annotations, which,
as of PEP 563, are stored simply as strings. This evaluation is done as of PEP 563, are stored simply as strings. This evaluation is done
in the module namespace containing the class, so all referenced types in the module namespace containing the class, so all referenced types
must be defined at that level. The exception is Typing types (Optional, must be defined at that level.
Union, etc.) which are often defined under an 'if TYPE_CHECKING'
conditional and thus not available at runtime, so are explicitly made
available during annotation evaluation.
""" """
PrepSession(explicit=True).prep_dataclass(cls, recursion_level=0) PrepSession(explicit=True).prep_dataclass(cls, recursion_level=0)
@ -200,7 +215,8 @@ def ioprepped(cls: Type[T]) -> Type[T]:
Note that in some cases it may not be possible to prep a dataclass Note that in some cases it may not be possible to prep a dataclass
immediately (such as when its type annotations refer to forward-declared immediately (such as when its type annotations refer to forward-declared
types). In these cases, dataclass_prep() should be explicitly called for types). In these cases, dataclass_prep() should be explicitly called for
the class once it is safe to do so. the class as soon as possible; ideally at module import time to expose any
errors as early as possible in execution.
""" """
ioprep(cls) ioprep(cls)
return cls return cls
@ -278,14 +294,14 @@ class PrepSession:
# types and prepping any contained dataclass types. # types and prepping any contained dataclass types.
for attrname, anntype in resolved_annotations.items(): for attrname, anntype in resolved_annotations.items():
anntype, iometa = _parse_annotated(anntype) anntype, ioattrs = _parse_annotated(anntype)
# If we found attached IOMeta data, make sure it contains # If we found attached IOAttrs data, make sure it contains
# valid values for the field it is attached to. # valid values for the field it is attached to.
if iometa is not None: if ioattrs is not None:
iometa.validate_for_field(cls, fields_by_name[attrname]) ioattrs.validate_for_field(cls, fields_by_name[attrname])
if iometa.storagename is not None: if ioattrs.storagename is not None:
storage_names_to_attr_names[iometa.storagename] = attrname storage_names_to_attr_names[ioattrs.storagename] = attrname
self.prep_type(cls, self.prep_type(cls,
attrname, attrname,
@ -325,15 +341,10 @@ class PrepSession:
# Everything below this point assumes the annotation type resolves # Everything below this point assumes the annotation type resolves
# to a concrete type. # to a concrete type.
if not isinstance(origin, type): if not isinstance(origin, type):
print('ORIGIN IS', origin, type(origin))
raise TypeError( raise TypeError(
f'Unsupported type found for \'{attrname}\' on {cls}:' f'Unsupported type found for \'{attrname}\' on {cls}:'
f' {anntype}') f' {anntype}')
# extras = get_args(anntype)
# if extras:
# print('FOUND EXTRAS FOR', anntype)
if origin in SIMPLE_TYPES: if origin in SIMPLE_TYPES:
return return
@ -417,6 +428,9 @@ class PrepSession:
self.prep_dataclass(origin, recursion_level=recursion_level + 1) self.prep_dataclass(origin, recursion_level=recursion_level + 1)
return return
if origin is bytes:
return
raise TypeError(f"Attr '{attrname}' on {cls} contains type '{anntype}'" raise TypeError(f"Attr '{attrname}' on {cls} contains type '{anntype}'"
f' which is not supported by dataclassio.') f' which is not supported by dataclassio.')
@ -508,9 +522,11 @@ def _get_origin(anntype: Any) -> Any:
class _Outputter: class _Outputter:
"""Validates or exports data contained in a dataclass instance.""" """Validates or exports data contained in a dataclass instance."""
def __init__(self, obj: Any, create: bool, coerce_to_float: bool) -> None: def __init__(self, obj: Any, create: bool, codec: Codec,
coerce_to_float: bool) -> None:
self._obj = obj self._obj = obj
self._create = create self._create = create
self._codec = codec
self._coerce_to_float = coerce_to_float self._coerce_to_float = coerce_to_float
def run(self) -> Any: def run(self) -> Any:
@ -533,11 +549,11 @@ class _Outputter:
anntype = prep.annotations[fieldname] anntype = prep.annotations[fieldname]
value = getattr(obj, fieldname) value = getattr(obj, fieldname)
anntype, iometa = _parse_annotated(anntype) anntype, ioattrs = _parse_annotated(anntype)
# If we're not storing default values for this fella, # If we're not storing default values for this fella,
# we can skip all output processing if we've got a default value. # we can skip all output processing if we've got a default value.
if iometa is not None and not iometa.store_default: if ioattrs is not None and not ioattrs.store_default:
default_factory: Any = field.default_factory # type: ignore default_factory: Any = field.default_factory # type: ignore
if default_factory is not dataclasses.MISSING: if default_factory is not dataclasses.MISSING:
if default_factory() == value: if default_factory() == value:
@ -556,8 +572,8 @@ class _Outputter:
if self._create: if self._create:
assert out is not None assert out is not None
storagename = (fieldname if storagename = (fieldname if
(iometa is None or iometa.storagename is None) (ioattrs is None or ioattrs.storagename is None)
else iometa.storagename) else ioattrs.storagename)
out[storagename] = outvalue out[storagename] = outvalue
# If there's extra-attrs stored on us, check/include them. # If there's extra-attrs stored on us, check/include them.
@ -712,19 +728,37 @@ class _Outputter:
if not isinstance(value, origin): if not isinstance(value, origin):
raise TypeError(f'Expected a {origin} for {fieldpath};' raise TypeError(f'Expected a {origin} for {fieldpath};'
f' found a {type(value)}.') f' found a {type(value)}.')
# We only support timezone-aware utc times. _ensure_datetime_is_timezone_aware(value)
if (value.tzinfo is not datetime.timezone.utc if self._codec is Codec.FIRESTORE:
and (_pytz_utc is None or value.tzinfo is not _pytz_utc)): return value
raise ValueError( assert self._codec is Codec.JSON
'datetime values must have timezone set as timezone.utc')
return [ return [
value.year, value.month, value.day, value.hour, value.minute, value.year, value.month, value.day, value.hour, value.minute,
value.second, value.microsecond value.second, value.microsecond
] if self._create else None ] if self._create else None
if origin is bytes:
return self._process_bytes(cls, fieldpath, value)
raise TypeError( raise TypeError(
f"Field '{fieldpath}' of type '{anntype}' is unsupported here.") f"Field '{fieldpath}' of type '{anntype}' is unsupported here.")
def _process_bytes(self, cls: Type, fieldpath: str, value: bytes) -> Any:
import base64
if not isinstance(value, bytes):
raise TypeError(f'Expected bytes for {fieldpath} on {cls};'
f' found a {type(value)}.')
if not self._create:
return None
# In JSON we convert to base64, but firestore directly supports bytes.
if self._codec is Codec.JSON:
return base64.b64encode(value).decode()
assert self._codec is Codec.FIRESTORE
return value
def _process_dict(self, cls: Type, fieldpath: str, anntype: Any, def _process_dict(self, cls: Type, fieldpath: str, anntype: Any,
value: dict) -> Any: value: dict) -> Any:
# pylint: disable=too-many-branches # pylint: disable=too-many-branches
@ -792,10 +826,12 @@ class _Inputter(Generic[T]):
def __init__(self, def __init__(self,
cls: Type[T], cls: Type[T],
codec: Codec,
coerce_to_float: bool, coerce_to_float: bool,
allow_unknown_attrs: bool = True, allow_unknown_attrs: bool = True,
discard_unknown_attrs: bool = False): discard_unknown_attrs: bool = False):
self._cls = cls self._cls = cls
self._codec = codec
self._coerce_to_float = coerce_to_float self._coerce_to_float = coerce_to_float
self._allow_unknown_attrs = allow_unknown_attrs self._allow_unknown_attrs = allow_unknown_attrs
self._discard_unknown_attrs = discard_unknown_attrs self._discard_unknown_attrs = discard_unknown_attrs
@ -871,9 +907,32 @@ class _Inputter(Generic[T]):
if issubclass(origin, datetime.datetime): if issubclass(origin, datetime.datetime):
return self._datetime_from_input(cls, fieldpath, value) return self._datetime_from_input(cls, fieldpath, value)
if origin is bytes:
return self._bytes_from_input(origin, fieldpath, value)
raise TypeError( raise TypeError(
f"Field '{fieldpath}' of type '{anntype}' is unsupported here.") f"Field '{fieldpath}' of type '{anntype}' is unsupported here.")
def _bytes_from_input(self, cls: Type, fieldpath: str,
value: Any) -> bytes:
"""Given input data, returns bytes."""
import base64
# For firestore, bytes are passed as-is. Otherwise they're encoded
# as base64.
if self._codec is Codec.FIRESTORE:
if not isinstance(value, bytes):
raise TypeError(f'Expected a bytes object for {fieldpath}'
f' on {cls}; got a {type(value)}.')
return value
assert self._codec is Codec.JSON
if not isinstance(value, str):
raise TypeError(f'Expected a string object for {fieldpath}'
f' on {cls}; got a {type(value)}.')
return base64.b64decode(value)
def _dataclass_from_input(self, cls: Type, fieldpath: str, def _dataclass_from_input(self, cls: Type, fieldpath: str,
values: dict) -> Any: values: dict) -> Any:
"""Given a dict, instantiates a dataclass of the given type. """Given a dict, instantiates a dataclass of the given type.
@ -885,7 +944,8 @@ class _Inputter(Generic[T]):
""" """
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
if not isinstance(values, dict): if not isinstance(values, dict):
raise TypeError("Expected a dict for 'values' arg.") raise TypeError(f'Expected a dict for {fieldpath} on {cls};'
f' got a {type(values)}.')
prep = PrepSession(explicit=False).prep_dataclass(cls, prep = PrepSession(explicit=False).prep_dataclass(cls,
recursion_level=0) recursion_level=0)
@ -920,7 +980,7 @@ class _Inputter(Generic[T]):
else: else:
fieldname = field.name fieldname = field.name
anntype = prep.annotations[fieldname] anntype = prep.annotations[fieldname]
anntype, _iometa = _parse_annotated(anntype) anntype, _ioattrs = _parse_annotated(anntype)
subfieldpath = (f'{fieldpath}.{fieldname}' subfieldpath = (f'{fieldpath}.{fieldname}'
if fieldpath else fieldname) if fieldpath else fieldname)
@ -1058,6 +1118,19 @@ class _Inputter(Generic[T]):
def _datetime_from_input(self, cls: Type, fieldpath: str, def _datetime_from_input(self, cls: Type, fieldpath: str,
value: Any) -> Any: value: Any) -> Any:
# For firestore we expect a datetime object.
if self._codec is Codec.FIRESTORE:
# Don't compare exact type here, as firestore can give us
# a subclass with extended precision.
if not isinstance(value, datetime.datetime):
raise TypeError(
f'Invalid input value for "{fieldpath}" on "{cls}";'
f' expected a datetime, got a {type(value).__name__}')
_ensure_datetime_is_timezone_aware(value)
return value
assert self._codec is Codec.JSON
# We expect a list of 7 ints. # We expect a list of 7 ints.
if type(value) is not list: if type(value) is not list:
raise TypeError( raise TypeError(
@ -1109,20 +1182,28 @@ class _Inputter(Generic[T]):
return tuple(out) return tuple(out)
def _parse_annotated(anntype: Any) -> Tuple[Any, Optional[IOMeta]]: def _ensure_datetime_is_timezone_aware(value: datetime.datetime) -> None:
"""Parse Annotated() constructs, returning annotated type & IOMeta data.""" # We only support timezone-aware utc times.
if (value.tzinfo is not datetime.timezone.utc
and (_pytz_utc is None or value.tzinfo is not _pytz_utc)):
raise ValueError(
'datetime values must have timezone set as timezone.utc')
def _parse_annotated(anntype: Any) -> Tuple[Any, Optional[IOAttrs]]:
"""Parse Annotated() constructs, returning annotated type & IOAttrs."""
# If we get an Annotated[foo, bar, eep] we take # If we get an Annotated[foo, bar, eep] we take
# foo as the actual type and we look for IOMeta instances in # foo as the actual type and we look for IOAttrs instances in
# bar/eep to affect our behavior. # bar/eep to affect our behavior.
iometa: Optional[IOMeta] = None ioattrs: Optional[IOAttrs] = None
if isinstance(anntype, _AnnotatedAlias): if isinstance(anntype, _AnnotatedAlias):
annargs = get_args(anntype) annargs = get_args(anntype)
for annarg in annargs[1:]: for annarg in annargs[1:]:
if isinstance(annarg, IOMeta): if isinstance(annarg, IOAttrs):
if iometa is not None: if ioattrs is not None:
raise RuntimeError( raise RuntimeError(
'Multiple IOMeta instances found for a' 'Multiple IOAttrs instances found for a'
' single annotation; this is not supported.') ' single annotation; this is not supported.')
iometa = annarg ioattrs = annarg
anntype = annargs[0] anntype = annargs[0]
return anntype, iometa return anntype, ioattrs