Changed implementation of DataclassStruct. It is now only nessesary to sublcass DataclassStruct and not to combine it with @dataclasses.dataclass. Also now the DataclassConstruct is included in the DataclassStruct class type itself.

This commit is contained in:
Tim Rid 2022-02-13 16:32:30 +01:00
parent 753e4282ee
commit b896457f90

View file

@ -14,19 +14,247 @@ from construct.lib.py3compat import bytestringtype, reprstring, unicodestringtyp
from construct_typed.generic import Adapter, Construct, Context, ParsedType, PathType
T = t.TypeVar("T")
class DataclassMixin:
# Static type inference support via __dataclass_transform__ implemented as per:
# https://github.com/microsoft/pyright/blob/1.1.135/specs/dataclass_transforms.md
def __dataclass_transform__(
*,
eq_default: bool = True,
order_default: bool = False,
kw_only_default: bool = False,
field_descriptors: t.Tuple[t.Union[type, t.Callable[..., t.Any]], ...] = (()),
) -> t.Callable[[T], T]:
return lambda a: a
DATACLASS_METADATA_KEY = "__construct_typed_subcon"
if t.TYPE_CHECKING:
# specialisation for constructs, that builds from none and dont have to be declared in the __init__ method
@t.overload
def csfield(
subcon: cs.Construct[ParsedType, None],
doc: t.Optional[str] = None,
parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None,
init: t.Literal[False] = False,
) -> ParsedType:
...
@t.overload
def csfield(
subcon: Construct[ParsedType, t.Any],
doc: t.Optional[str] = None,
parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None,
init: bool = True,
) -> ParsedType:
...
def csfield(
subcon: Construct[ParsedType, t.Any],
doc: t.Optional[str] = None,
parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None,
init: bool = True,
) -> ParsedType:
"""
Mixin for the dataclasses which are passed to "DataclassStruct" and "DataclassBitStruct".
Helper method for "DataclassStruct" and "DataclassBitStruct" to create the dataclass fields.
Note: This implementation is different to the 'cs.Container' of the original 'construct'
library. In the original 'cs.Container' some names like "update", "keys", "items", ... can
only accessed via key access (square brackets) and not via attribute access (dot operator),
because they are also method names. This implementation is based on "dataclasses.dataclass"
which only uses modul-level instead of instance-level helper methods.So no instance-level
methods exists and every name can be used.
This method also processes Const and Default, to pass these values als default values to the dataclass.
"""
orig_subcon = subcon
# Rename subcon, if doc or parsed are available
if (doc is not None) or (parsed is not None):
if doc is not None:
doc = textwrap.dedent(doc).strip("\n")
subcon = cs.Renamed(subcon, newdocs=doc, newparsed=parsed)
if orig_subcon.flagbuildnone is True:
init = False
default = None
else:
init = True
default = dataclasses.MISSING
# Set default values in case of special sucons
if isinstance(orig_subcon, cs.Const):
const_subcon: "cs.Const[t.Any, t.Any, t.Any, t.Any]" = orig_subcon
default = const_subcon.value
elif isinstance(orig_subcon, cs.Default):
default_subcon: "cs.Default[t.Any, t.Any, t.Any, t.Any]" = orig_subcon
if callable(default_subcon.value):
default = None # context lambda is only defined at parsing/building
else:
default = default_subcon.value
return t.cast(
ParsedType,
dataclasses.field(
default=default,
init=init,
metadata={DATACLASS_METADATA_KEY: subcon},
),
)
class DataclassConstruct(Adapter[t.Any, t.Any, T, T]):
r"""
TODO: Add Documentation
"""
subcon: "cs.Struct[t.Any, t.Any]"
if t.TYPE_CHECKING:
def __new__(
cls,
dc_type: t.Type[T],
reverse: bool = False,
) -> "DataclassConstruct[T]":
...
def __init__(
self,
dc_type: t.Type[T],
reverse: bool = False,
) -> None:
if not isinstance(dc_type, DataclassStruct):
raise TypeError(f"'{repr(dc_type)}' has to be a 'DataclassStruct'")
if not dataclasses.is_dataclass(dc_type):
raise TypeError(f"'{repr(dc_type)}' has to be a 'dataclasses.dataclass'")
self.dc_type = dc_type
self.reverse = reverse
# get all fields from the dataclass
fields = dataclasses.fields(self.dc_type)
if self.reverse:
fields = tuple(reversed(fields))
# extract the construct formats from the struct_type
subcon_fields = {}
for field in fields:
subcon_fields[field.name] = field.metadata[DATACLASS_METADATA_KEY]
# init adatper
super().__init__(cs.Struct(**subcon_fields)) # type: ignore
def __getattr__(self, name: str) -> t.Any:
return getattr(self.subcon, name)
def _decode(
self, obj: "cs.Container[t.Any]", context: Context, path: PathType
) -> T:
# get all fields from the dataclass
fields = dataclasses.fields(self.dc_type)
# extract all fields from the container, that are used for create the dataclass object
dc_init = {}
for field in fields:
if field.init:
value = obj[field.name]
dc_init[field.name] = value
# create object of dataclass
dc = self.dc_type(**dc_init) # type: ignore
# extract all other values from the container, an pass it to the dataclass
for field in fields:
if not field.init:
value = obj[field.name]
setattr(dc, field.name, value)
return dc
def _encode(self, obj: T, context: Context, path: PathType) -> t.Dict[str, t.Any]:
if not isinstance(obj, self.dc_type):
raise TypeError(f"'{repr(obj)}' has to be of type {repr(self.dc_type)}")
# get all fields from the dataclass
fields = dataclasses.fields(self.dc_type)
# extract all fields from the container, that are used for create the dataclass object
ret_dict: t.Dict[str, t.Any] = {}
for field in fields:
value = getattr(obj, field.name)
ret_dict[field.name] = value
return ret_dict
# Helper object for defining the `constr` of a `struct`. Will be replaced with the proper construct, when class is created.
this_struct: Construct[t.Any, t.Any] = Construct()
def _replace_this_struct(constr: "Construct[t.Any, t.Any]", replacement: t.Any):
"""Recursive search for `this_struct` in all SubConstructs and replace it with AttrsStruct"""
subcon = getattr(constr, "subcon", None)
if subcon is this_struct:
setattr(constr, "subcon", replacement)
elif subcon is not None:
_replace_this_struct(subcon, replacement)
else:
raise ValueError(
"Could not find `this_struct`. Only SubConstructs are supported"
)
@__dataclass_transform__(field_descriptors=(csfield,))
class DataclassStruct:
"""
Adapter for a dataclasses for optimised type hints / static autocompletion in comparision to the original Struct.
Before this construct can be created a dataclasses.dataclass type must be created, which must also derive from DataclassMixin. In this dataclass all fields must be assigned to a construct type using csfield.
Internally, all fields are converted to a normal Struct, which does the actual parsing/building.
Parses to a dataclasses.dataclass instance, and builds from such instance. Size is the sum of all subcon sizes, unless any subcon raises SizeofError.
:param constr: This can be used if the structure is nested inside a Subconstruct. To represent this struct use the constant `this_struct`.
:param reverse: Flag if the fields of the dataclass should be reversed
Example::
>>> from construct import Bytes, Int8ub, this
>>> from construct_typed import DataclassMixin, DataclassStruct, csfield, construct
... class Image(DataclassStruct):
... width: int = csfield(Int8ub)
... height: int = csfield(Int8ub)
... pixels: bytes = csfield(Bytes(this.height * this.width))
>>> d = construct(Image)
>>> d.parse(b"\x01\x0212")
Image(width=1, height=2, pixels=b'12')
"""
@classmethod
def __init_subclass__(
cls,
constr: "cs.Construct[t.Any, t.Any]" = this_struct,
reverse_fields: bool = False,
):
# validate types
if not isinstance(constr, cs.Construct): # type: ignore
raise ValueError("`constr` parameter has to be an `Construct` object")
if not isinstance(reverse_fields, bool): # type: ignore
raise ValueError("`reverse_fields` parameter has to be an `bool` object")
# create dataclass
cls = dataclasses.dataclass(cls)
# create construct format
dc_constr = DataclassConstruct(cls, reverse_fields)
if constr is this_struct:
constr = dc_constr
else:
_replace_this_struct(constr, dc_constr)
# save construct format and make the class compatible to `Constructable` protocol
setattr(cls, "__construct__", lambda: constr)
return cls
# the `construct` library is using the [] access internally, so struct objects
# should also make this possible and not only via the dot access.
def __getitem__(self, key: str) -> t.Any:
return getattr(self, key)
@ -72,201 +300,42 @@ class DataclassMixin:
text.append(indentation.join(str(v).split("\n")))
return "".join(text)
def csfield(
subcon: Construct[ParsedType, t.Any],
doc: t.Optional[str] = None,
parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None,
) -> ParsedType:
"""
Helper method for "DataclassStruct" and "DataclassBitStruct" to create the dataclass fields.
This method also processes Const and Default, to pass these values als default values to the dataclass.
"""
orig_subcon = subcon
# Rename subcon, if doc or parsed are available
if (doc is not None) or (parsed is not None):
if doc is not None:
doc = textwrap.dedent(doc).strip("\n")
subcon = cs.Renamed(subcon, newdocs=doc, newparsed=parsed)
if orig_subcon.flagbuildnone is True:
init = False
default = None
else:
init = True
default = dataclasses.MISSING
# Set default values in case of special sucons
if isinstance(orig_subcon, cs.Const):
const_subcon: "cs.Const[t.Any, t.Any, t.Any, t.Any]" = orig_subcon
default = const_subcon.value
elif isinstance(orig_subcon, cs.Default):
default_subcon: "cs.Default[t.Any, t.Any, t.Any, t.Any]" = orig_subcon
if callable(default_subcon.value):
default = None # context lambda is only defined at parsing/building
else:
default = default_subcon.value
return t.cast(
ParsedType,
dataclasses.field(
default=default,
init=init,
metadata={"subcon": subcon},
),
)
DataclassType = t.TypeVar("DataclassType", bound=DataclassMixin)
class DataclassStruct(Adapter[t.Any, t.Any, DataclassType, DataclassType]):
"""
Adapter for a dataclasses for optimised type hints / static autocompletion in comparision to the original Struct.
Before this construct can be created a dataclasses.dataclass type must be created, which must also derive from DataclassMixin. In this dataclass all fields must be assigned to a construct type using csfield.
Internally, all fields are converted to a Struct, which does the actual parsing/building.
Parses to a dataclasses.dataclass instance, and builds from such instance. Size is the sum of all subcon sizes, unless any subcon raises SizeofError.
:param dc_type: Type of the dataclass, which also inherits from DataclassMixin
:param reverse: Flag if the fields of the dataclass should be reversed
Example::
>>> import dataclasses
>>> from construct import Bytes, Int8ub, this
>>> from construct_typed import DataclassMixin, DataclassStruct, csfield
>>> @dataclasses.dataclass
... class Image(DataclassMixin):
... width: int = csfield(Int8ub)
... height: int = csfield(Int8ub)
... pixels: bytes = csfield(Bytes(this.height * this.width))
>>> d = DataclassStruct(Image)
>>> d.parse(b"\x01\x0212")
Image(width=1, height=2, pixels=b'12')
"""
subcon: "cs.Struct[t.Any, t.Any]"
if t.TYPE_CHECKING:
def __new__(
cls,
dc_type: t.Type[DataclassType],
reverse: bool = False,
) -> "DataclassStruct[DataclassType]":
@classmethod
def __construct__(cls: t.Type[T]) -> "DataclassConstruct[T]":
...
def __init__(
self,
dc_type: t.Type[DataclassType],
reverse: bool = False,
) -> None:
if not issubclass(dc_type, DataclassMixin):
raise TypeError(f"'{repr(dc_type)}' has to be a '{repr(DataclassMixin)}'")
if not dataclasses.is_dataclass(dc_type):
raise TypeError(f"'{repr(dc_type)}' has to be a 'dataclasses.dataclass'")
self.dc_type = dc_type
self.reverse = reverse
# get all fields from the dataclass
fields = dataclasses.fields(self.dc_type)
if self.reverse:
fields = tuple(reversed(fields))
# extract the construct formats from the struct_type
subcon_fields = {}
for field in fields:
subcon_fields[field.name] = field.metadata["subcon"]
# init adatper
super().__init__(cs.Struct(**subcon_fields)) # type: ignore
def __getattr__(self, name: str) -> t.Any:
return getattr(self.subcon, name)
def _decode(
self, obj: "cs.Container[t.Any]", context: Context, path: PathType
) -> DataclassType:
# get all fields from the dataclass
fields = dataclasses.fields(self.dc_type)
# extract all fields from the container, that are used for create the dataclass object
dc_init = {}
for field in fields:
if field.init:
value = obj[field.name]
dc_init[field.name] = value
# create object of dataclass
dc = self.dc_type(**dc_init) # type: ignore
# extract all other values from the container, an pass it to the dataclass
for field in fields:
if not field.init:
value = obj[field.name]
setattr(dc, field.name, value)
return dc
def _encode(
self, obj: DataclassType, context: Context, path: PathType
) -> t.Dict[str, t.Any]:
if not isinstance(obj, self.dc_type):
raise TypeError(f"'{repr(obj)}' has to be of type {repr(self.dc_type)}")
# get all fields from the dataclass
fields = dataclasses.fields(self.dc_type)
# extract all fields from the container, that are used for create the dataclass object
ret_dict: t.Dict[str, t.Any] = {}
for field in fields:
value = getattr(obj, field.name)
ret_dict[field.name] = value
return ret_dict
def DataclassBitStruct(
dc_type: t.Type[DataclassType], reverse: bool = False
) -> t.Union[
"cs.Transformed[DataclassType, DataclassType]",
"cs.Restreamed[DataclassType, DataclassType]",
]:
class DataclassBitStruct(DataclassStruct):
r"""
Makes a DataclassStruct inside a Bitwise.
See :class:`~construct.core.Bitwise` and :class:`~construct_typed.dataclass_struct.DatclassStruct` for semantics and raisable exceptions.
:param dc_type: Type of the dataclass, which also inherits from DataclassMixin
:param reverse: Flag if the fields of the dataclass should be reversed
:param constr: TODO
:param reverse_fields: Flag if the fields of the dataclass should be reversed
Example::
DataclassBitStruct <--> Bitwise(DataclassStruct(...))
>>> import dataclasses
TODO:
>>> from construct import BitsInteger, Flag, Nibble, Padding
>>> from construct_typed import DataclassBitStruct, DataclassMixin, csfield
>>> @dataclasses.dataclass
... class TestDataclass(DataclassMixin):
>>> from construct_typed import DataclassBitStruct, csfield, construct
... class TestDataclass(DataclassBitStruct):
... a: int = csfield(Flag)
... b: int = csfield(Nibble)
... c: int = csfield(BitsInteger(10))
... d: None = csfield(Padding(1))
>>> d = DataclassBitStruct(TestDataclass)
>>> d = construct(TestDataclass)
>>> d.parse(b"\x01\x02")
TestDataclass(a=False, b=0, c=129, d=None)
"""
return cs.Bitwise(DataclassStruct(dc_type, reverse))
# support legacy names
TStruct = DataclassStruct
TBitStruct = DataclassBitStruct
TContainerMixin = DataclassMixin
TContainerBase = DataclassMixin
TStructField = csfield
sfield = csfield
@classmethod
def __init_subclass__(
cls,
constr: "cs.Construct[t.Any, t.Any]" = this_struct,
reverse_fields: bool = False,
):
cls = DataclassStruct.__init_subclass__.__func__(cls, cs.Bitwise(constr), reverse_fields) # type: ignore
return cls