diff --git a/construct-stubs/core.pyi b/construct-stubs/core.pyi index 456ab4a..4172880 100644 --- a/construct-stubs/core.pyi +++ b/construct-stubs/core.pyi @@ -585,9 +585,15 @@ class Const(Subconstruct[t.Any, t.Any, ParsedType, BuildTypes]): class Computed(Construct[ParsedType, None]): func: ConstantOrContextLambda2[ParsedType] + @t.overload def __init__( self, - func: ConstantOrContextLambda2[ParsedType], + func: t.Callable[[Context], ParsedType], + ) -> None: ... + @t.overload + def __init__( + self, + func: ParsedType, ) -> None: ... Index: Construct[int, t.Any] @@ -921,9 +927,14 @@ class Seek(Construct[int, None]): whence: ConstantOrContextLambda[WHENCE] = ..., ) -> None: ... -Tell: Construct[int, None] -Pass: Construct[None, None] -Terminated: Construct[None, None] +class TellType(Construct[int, None]): ... +Tell: TellType + +class PassType(Construct[None, None]): ... +Pass: PassType + +class TerminatedType(Construct[None, None]): ... +Terminated: TerminatedType # =============================================================================== # tunneling and byte/bit swapping diff --git a/construct_typed/__init__.py b/construct_typed/__init__.py index 9ea0ccf..7dbba62 100644 --- a/construct_typed/__init__.py +++ b/construct_typed/__init__.py @@ -7,6 +7,7 @@ TContainerMixin, TStruct, TStructField, + csdefault_field, csfield, sfield, ) @@ -30,6 +31,7 @@ "TContainerMixin", "TStruct", "TStructField", + "csdefault_field", "csfield", "sfield", "EnumBase", diff --git a/construct_typed/dataclass_struct.py b/construct_typed/dataclass_struct.py index 6315857..70b62f3 100644 --- a/construct_typed/dataclass_struct.py +++ b/construct_typed/dataclass_struct.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import typing_extensions import dataclasses import textwrap import typing as t @@ -11,9 +12,196 @@ ) from construct.lib.py3compat import bytestringtype, reprstring, unicodestringtype -from .generic_wrapper import Adapter, Construct, Context, ParsedType, PathType +from construct_typed.generic_wrapper import Adapter, Construct, Context, PathType +# csfield/csdefault_field need an *invariant* type var, because ParsedType (covariant) +# cannot be used as a parameter type (mypy: "Cannot use a covariant type variable as a parameter"). +FieldType = t.TypeVar("FieldType") + +# Overload 1: Const → init=False (no __init__ parameter, has internal default) +@t.overload +def csfield( + subcon: "cs.Const[FieldType, t.Any]", + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + init: t.Literal[False] = ..., +) -> FieldType: ... + + +# Overload 2: Rebuild → init=False (variable ParsedType, no default) +@t.overload +def csfield( + subcon: "cs.Rebuild[FieldType, t.Any]", + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + init: t.Literal[False] = ..., +) -> FieldType: ... + + +# Overload 3: Computed → init=False (variable ParsedType, no default) +@t.overload +def csfield( + subcon: "cs.Computed[FieldType]", + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + init: t.Literal[False] = ..., +) -> FieldType: ... + + +# Overload 4: Padded[None,None] = Padding() → init=False, returns None +@t.overload +def csfield( + subcon: "cs.Padded[None, None]", + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + init: t.Literal[False] = ..., +) -> None: ... + + +# Overload 5: cs.Tell → init=False, returns int +@t.overload +def csfield( + subcon: "cs.TellType", + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + init: t.Literal[False] = ..., +) -> int: ... + + +# Overload 6: cs.Pass, cs.Terminated → init=False, returns None +@t.overload +def csfield( + subcon: "cs.PassType | cs.TerminatedType", + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + init: t.Literal[False] = ..., +) -> None: ... + + +# Overload 7: all other ctors with explicit default +@t.overload +def csfield( + subcon: Construct[FieldType, t.Any], + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + default: FieldType = ..., +) -> FieldType: ... + + +# Overload 8: all other ctors → mandatory field (no default) +@t.overload +def csfield( + subcon: Construct[FieldType, t.Any], + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + kw_only: bool = ..., +) -> FieldType: ... + + +def csfield( + subcon: Construct[FieldType, t.Any], + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + kw_only: bool = False, + **_kwargs: t.Any, # absorbs `init=` et al. from overloads (only for type checkers) +) -> FieldType: + """ + Helper method for "DataclassStruct" and "DataclassBitStruct" to create the dataclass fields. + + This method also processes Const and Default, to pass these values as default values to the dataclass. + However, to have proper Pyright support for Default, use `csdefault_field()` instead. + + When using any fields _without_ default *after* a Default field, these must be marked as `kw_only` and be + passed "by keyword" to the construct's ctor. Otherwise, Pyright will condem your eternal soul to an + everlasting vacation on a Caribbean beach. You have been warned! And Pyright will tell you. + """ + 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 = orig_subcon + default = const_subcon.value + elif isinstance(orig_subcon, cs.Default): + default_subcon = orig_subcon + # Default fields are always included in __init__ (as optional param with default), regardless of flagbuildnone. + init = True + if callable(default_subcon.value): + default = None # context lambda is only defined at parsing/building + else: + default = default_subcon.value + + return t.cast( + FieldType, + dataclasses.field( + default=default, + init=init, + kw_only=kw_only, + metadata={"subcon": subcon}, + ), + ) + + +def csdefault_field( + subcon: Construct[FieldType, t.Any], + default: t.Union[FieldType, t.Callable[[Context], t.Any]], + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, +) -> FieldType: + """ + Helper method for "DataclassStruct" and "DataclassBitStruct" to create the dataclass fields. + + Use ONLY this method to ONLY process Default fields! + """ + cs_default = cs.Default(subcon, default) + + subcon_field: cs.Renamed[FieldType, t.Any | None] | cs.Default[FieldType, t.Any] + if (doc is not None) or (parsed is not None): + if doc is not None: + doc = textwrap.dedent(doc).strip("\n") + subcon_field = cs.Renamed(cs_default, newdocs=doc, newparsed=parsed) + else: + subcon_field = cs_default + + # For lambda defaults, use None as the dataclass default (value only known at parse time) + dc_default: t.Any = None if callable(default) else default + + return t.cast( + FieldType, + dataclasses.field( + default=dc_default, + init=True, + metadata={"subcon": subcon_field}, + ), + ) + + +# Note: `csdefault_field` does not need to be declared in `field_specifiers`. Pyright evaluates the +# return type of the method which always exists, thus creating the desired effect that the field is +# optional in the construct declaration. +@typing_extensions.dataclass_transform(field_specifiers=(csfield,)) class DataclassMixin: """ Mixin for the dataclasses which are passed to "DataclassStruct" and "DataclassBitStruct". @@ -74,52 +262,6 @@ def __str__(self) -> str: 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]" = orig_subcon - default = const_subcon.value - elif isinstance(orig_subcon, cs.Default): - default_subcon: "cs.Default[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) @@ -151,7 +293,8 @@ class DataclassStruct(Adapter[t.Any, t.Any, DataclassType, DataclassType]): Image(width=1, height=2, pixels=b'12') """ - subcon: "cs.Struct" # type: ignore + subcon: "cs.Struct" # type: ignore + def __init__( self, dc_type: t.Type[DataclassType], diff --git a/pyproject.toml b/pyproject.toml index 439f996..af54bdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,10 @@ config-settings = { editable_mode = "compat" } strict = true warn_unused_ignores = false +[[tool.mypy.overrides]] +module = ["tests.test_typed", "tests.test_typed_pyright"] +disable_error_code = ["call-arg", "arg-type"] + [tool.pyright] typeCheckingMode = "strict" exclude = [ @@ -134,7 +138,6 @@ exclude = [ # These warnings should be activated as "error" in the future, but for now we don't want to refactor the entire codebase right now. # Valid values: "error", "warn", "ignore" [tool.ty.rules] -invalid-argument-type = "ignore" invalid-assignment = "ignore" invalid-generic-class = "ignore" invalid-legacy-type-variable = "ignore" @@ -157,12 +160,8 @@ allowed-confusables = ["×", "–", "‘", "’"] # These warnings should be activated as "error" in the future, but for now we don't want to refactor the entire codebase right now. ignore = [ - "E711", # Comparison to `None` should be `cond is None` - "E712", # Avoid equality comparisons to `True` - "E721", # Use `is` and `is not` for type comparisons, or `isinstance()` for isinstance checks - "E741", # Ambiguous variable name: `...` - "F403", # `from ... import *` used; unable to detect undefined names - "F405", # `...` may be undefined, or defined from star imports + "F403", # `from ... import *` used; unable to detect undefined names + "F405", # `...` may be undefined, or defined from star imports ] [tool.poe.tasks.test] @@ -201,4 +200,4 @@ sequence = [ "lint", "typecheck", "test", -] \ No newline at end of file +] diff --git a/tests/test_core.py b/tests/test_core.py index f1a1555..6064a09 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -# mypy: no-warn-unused-ignores from .declarativeunittest import raises, common, ident, devzero from construct.core import * from construct import * @@ -142,13 +141,13 @@ def test_formatfield_floats_randomized() -> None: assert d.build(d.parse(b)) == b def test_formatfield_bool_issue_901() -> None: - d = FormatField(">","?") - assert d.parse(b"\x01") == True - assert d.parse(b"\xff") == True - assert d.parse(b"\x00") == False - assert d.build(True) == b"\x01" - assert d.build(False) == b"\x00" - assert d.sizeof() == 1 + d = FormatField(">","?") + assert d.parse(b"\x01") is True + assert d.parse(b"\xff") is True + assert d.parse(b"\x00") is False + assert d.build(True) == b"\x01" + assert d.build(False) == b"\x00" + assert d.sizeof() == 1 def test_bytesinteger() -> None: d = BytesInteger(0) @@ -291,7 +290,7 @@ def test_flag() -> None: d = Flag common(d, b"\x00", False, 1) common(d, b"\x01", True, 1) - assert d.parse(b"\xff") == True + assert d.parse(b"\xff") is True def test_enum() -> None: d = Enum(Byte, one=1, two=2, four=4, eight=8) @@ -308,7 +307,7 @@ def test_enum() -> None: assert d.one == "one" assert int(d.one) == 1 assert raises(d.build, "unknown") == MappingError - assert raises(lambda: d.missing) == AttributeError + assert raises(lambda: d.missing) is AttributeError def test_enum_enum34() -> None: import enum @@ -352,7 +351,7 @@ def test_enum_issue_298() -> None: # Flag is not affected by same bug d = Struct( "flag" / Flag, - Check(lambda ctx: ctx.flag == True), + Check(lambda ctx: ctx.flag is True), ) common(d, b"\x01", dict(flag=True), 1) @@ -387,7 +386,7 @@ def test_flagsenum() -> None: assert raises(d.build, "unknown") == MappingError assert d.one == "one" assert d.one|d.two == "one|two" - assert raises(lambda: d.missing) == AttributeError + assert raises(lambda: d.missing) is AttributeError def test_flagsenum_enum34() -> None: import enum @@ -421,8 +420,8 @@ def test_struct() -> None: common(Struct("a"/Int16ub, "b"/Int8ub), b"\x00\x01\x02", Container(a=1,b=2), 3) common(Struct("a"/Struct("b"/Byte)), b"\x01", Container(a=Container(b=1)), 1) common(Struct(Const(b"\x00"), Padding(1), Pass, Terminated), bytes(2), {}, SizeofError) - assert raises(Struct("missingkey"/Byte).build, {}) == KeyError - assert raises(Struct(Bytes(this.missing)).sizeof) == SizeofError + assert raises(Struct("missingkey"/Byte).build, {}) is KeyError + assert raises(Struct(Bytes(this.missing)).sizeof) is SizeofError d = Struct(Computed(7), Const(b"JPEG"), Pass, Terminated) assert d.build(None) == d.build({}) @@ -534,22 +533,22 @@ def test_computed() -> None: common(Computed(lambda ctx: 255), b"", 255, 0) # type: ignore assert Computed(255).build(None) == b"" assert Struct(Computed(255)).build({}) == b"" - assert raises(Computed(this.missing).parse, b"") == KeyError - assert raises(Computed(this["missing"]).parse, b"") == KeyError + assert raises(Computed(this.missing).parse, b"") is KeyError + assert raises(Computed(this["missing"]).parse, b"") is KeyError def test_index() -> None: d1 = Array(3, Bytes(this._index+1)) common(d1, b"abbccc", [b"a", b"bb", b"ccc"]) d2 = GreedyRange(Bytes(this._index+1)) common(d2, b"abbccc", [b"a", b"bb", b"ccc"]) - d3 = RepeatUntil(lambda o,l,ctx: ctx._index == 2, Bytes(this._index+1)) + d3 = RepeatUntil(lambda o, lst, ctx: ctx._index == 2, Bytes(this._index+1)) common(d3, b"abbccc", [b"a", b"bb", b"ccc"]) d4 = Array(3, Struct("i" / Index)) common(d4, b"", [Container(i=0),Container(i=1),Container(i=2)], 0) d5 = GreedyRange(Struct("i" / Index, "d" / Bytes(this.i+1))) common(d5, b"abbccc", [Container(i=0,d=b"a"),Container(i=1,d=b"bb"),Container(i=2,d=b"ccc")]) - d6 = RepeatUntil(lambda o,l,ctx: ctx._index == 2, Index) + d6 = RepeatUntil(lambda o, lst, ctx: ctx._index == 2, Index) common(d6, b"", [0,1,2]) def test_rebuild() -> None: @@ -632,12 +631,12 @@ def test_focusedseq() -> None: common(FocusedSeq(this._.s, Const(b"MZ"), "num"/Byte, Terminated), b"MZ\xff", 255, SizeofError, s="num") d = FocusedSeq("missing", Pass) - assert raises(d.parse, b"") == UnboundLocalError - assert raises(d.build, {}) == UnboundLocalError + assert raises(d.parse, b"") is UnboundLocalError + assert raises(d.build, {}) is UnboundLocalError assert raises(d.sizeof) == 0 d = FocusedSeq(this.missing, Pass) - assert raises(d.parse, b"") == KeyError - assert raises(d.build, {}) == KeyError + assert raises(d.parse, b"") is KeyError + assert raises(d.build, {}) is KeyError assert raises(d.sizeof) == 0 def test_pickled() -> None: @@ -741,11 +740,11 @@ def test_hexdump_regression_issue_188() -> None: def test_union() -> None: d = Union(None, "a"/Bytes(2), "b"/Int16ub) assert d.parse(b"\x01\x02") == Container(a=b"\x01\x02", b=0x0102) - assert raises(Union(123, Pass).parse, b"") == KeyError - assert raises(Union("missing", Pass).parse, b"") == KeyError + assert raises(Union(123, Pass).parse, b"") is KeyError + assert raises(Union("missing", Pass).parse, b"") is KeyError assert d.build(dict(a=b"zz")) == b"zz" assert d.build(dict(b=0x0102)) == b"\x01\x02" - assert raises(d.build, {}) == UnionError + assert raises(d.build, {}) is UnionError d = Union(None, "a"/Bytes(2), "b"/Int16ub, Pass) assert d.build({}) == b"" @@ -795,8 +794,8 @@ def test_optional() -> None: d = Optional(Int32ul) assert d.parse(b"\x01\x00\x00\x00") == 1 assert d.build(1) == b"\x01\x00\x00\x00" - assert d.parse(b"???") == None - assert d.parse(b"") == None + assert d.parse(b"???") is None + assert d.parse(b"") is None assert d.build(None) == b"" assert raises(d.sizeof) == SizeofError @@ -844,7 +843,7 @@ def test_switch() -> None: d = Switch(this.x, {1:Int8ub, 2:Int16ub, 4:Int32ub}) common(d, b"\x01", 0x01, 1, x=1) common(d, b"\x01\x02", 0x0102, 2, x=2) - assert d.parse(b"", x=255) == None + assert d.parse(b"", x=255) is None assert d.build(None, x=255) == b"" assert raises(d.sizeof) == SizeofError assert raises(d.sizeof, x=1) == 1 @@ -933,7 +932,7 @@ def test_pointer() -> None: def test_peek() -> None: d1 = Peek(Int8ub) assert d1.parse(b"\x01") == 1 - assert d1.parse(b"") == None + assert d1.parse(b"") is None assert d1.build(1) == b"" assert d1.build(None) == b"" assert d1.sizeof() == 0 @@ -1284,8 +1283,8 @@ def _parse(self, stream, context, path): import warnings warnings.warn( "wrong checksum, read %r, computed %r, path %s" % ( - hash1 if not isinstance(hash1,bytestringtype) else binascii.hexlify(hash1), - hash2 if not isinstance(hash2,bytestringtype) else binascii.hexlify(hash2), + hash1 if not isinstance(hash1, bytestringtype) else binascii.hexlify(hash1), + hash2 if not isinstance(hash2, bytestringtype) else binascii.hexlify(hash2), path), ChecksumWarning ) @@ -1306,7 +1305,8 @@ def _sizeof(self, context, path): )), "checksum" / Checksum2(Bytes(64), lambda data: hashlib.sha512(data).digest(), this.fields.data), ) - d.parse(bytes(66)) + with pytest.warns(ChecksumWarning): + d.parse(bytes(66)) def test_compressed_zlib() -> None: zeros = bytes(10000) @@ -1826,7 +1826,7 @@ def test_from_issue_269() -> None: d = Struct("enabled" / Byte, a) assert d.build(dict(enabled=1)) == b"\x01\x00\x00" assert d.build(dict(enabled=0)) == b"\x00" - + d = Struct("enabled" / Byte, "pad" / If(this.enabled, Padding(2))) assert d.build(dict(enabled=1)) == b"\x01\x00\x00" assert d.build(dict(enabled=0)) == b"\x00" @@ -1898,7 +1898,7 @@ def test_from_issue_781() -> None: x = d2.parse(b"\x01") assert x.animal == "giraffe" # works - assert x.animal == d2.animal.giraffe # type: ignore # AttributeError: 'Transformed' object has no attribute 'animal' + assert x.animal == d2.animal.giraffe # type: ignore # AttributeError: 'Transformed' object has no attribute 'animal' def test_this_expresion_compare_container() -> None: st = Struct( @@ -2397,4 +2397,3 @@ def _decode(self, obj, context, path): assert ReversedList(Array(4, Byte)).build([1, 2, 3, 4]) == b'\x04\x03\x02\x01' assert ReversedList(Array(4, Byte)).parse(b'\x01\x02\x03\x04') == [4, 3, 2, 1] - \ No newline at end of file diff --git a/tests/test_typed.py b/tests/test_typed.py index f6bcd2a..9d21b47 100644 --- a/tests/test_typed.py +++ b/tests/test_typed.py @@ -7,7 +7,7 @@ import construct as cs import construct_typed as cst -from construct_typed import DataclassBitStruct, DataclassMixin, DataclassStruct, csfield +from construct_typed import DataclassBitStruct, DataclassMixin, DataclassStruct, csdefault_field, csfield from .declarativeunittest import common, raises, setattrs @@ -17,22 +17,64 @@ def test_dataclass_const_default() -> None: class ConstDefaultTest(DataclassMixin): const_bytes: bytes = csfield(cs.Const(b"BMP")) const_int: int = csfield(cs.Const(5, cs.Int8ub)) - default_int: int = csfield(cs.Default(cs.Int8ub, 28)) - default_lambda: bytes = csfield( - cs.Default(cs.Bytes(cs.this.const_int), lambda ctx: bytes(ctx.const_int)) - ) - - a = ConstDefaultTest() + default_int: int = csdefault_field(cs.Int8ub, 8) + default_lambda: bytes = csdefault_field(cs.Bytes(cs.this.default_int), lambda ctx: bytes(ctx.default_int)) + computed: bytes = csfield(cs.Computed(lambda ctx: bytes(i + 49 for i in range(ctx.default_int)))) + # Construct allows to put non-default values after Default. Dataclass and Pyright don't like that too much. It is necessary to + # specify the field `kw_only` and pass it "by keyword". + normal_int: int = csfield(cs.Int8ub, kw_only=True) + const_int2: int = csfield(cs.Const(5, cs.Int8ub)) + + format = DataclassStruct(ConstDefaultTest) + + a = ConstDefaultTest( + normal_int=7, + ) assert a.const_bytes == b"BMP" assert a.const_int == 5 - assert a.default_int == 28 - assert a.default_lambda == None + assert a.default_int == 8 + assert a.default_lambda is None + assert a.computed is None + assert format.build(a) == b"BMP\x05\x08\x00\x00\x00\x00\x00\x00\x00\x00\x07\x05" + a = format.parse(format.build(a)) + assert a.default_int == 8 + assert a.default_lambda == bytes(8) + assert a.computed == b"12345678" + + # Overriding Default-Values should be OK and modify the `computed` value. + b = ConstDefaultTest( + default_int=4, + default_lambda=b"TEST", + normal_int=1, + ) + b = format.parse(format.build(b)) + assert b.default_int == 4 + assert b.default_lambda == b"TEST" + assert b.computed == b"1234" + + +def test_dataclass_padded() -> None: + @dataclasses.dataclass + class PaddingTest(DataclassMixin): + padding: t.Optional[bytes] = csfield(cs.Padding(1)) + padded_pass: t.Optional[bytes] = csfield(cs.Padded(2, cs.Pass)) + padded_bytes: bytes = csfield(cs.Padded(7, cs.Bytes(5))) + padded_string: str = csfield(cs.PaddedString(4, "utf-8")) + + format = DataclassStruct(PaddingTest) + + a = PaddingTest(padded_bytes=b"12345", padded_string="abc") + assert a.padding is None + assert a.padded_pass is None + assert a.padded_bytes == b"12345" + assert a.padded_string == "abc" + assert format.build(a) == b"\x00\x00\x0012345\x00\x00abc\x00" def test_dataclass_access() -> None: @dataclasses.dataclass class TestTContainer(DataclassMixin): - a: t.Optional[int] = csfield(cs.Const(1, cs.Byte)) + a: int = csfield(cs.Const(1, cs.Byte)) b: int = csfield(cs.Int8ub) tcontainer = TestTContainer(b=2) @@ -51,40 +93,32 @@ class TestTContainer(DataclassMixin): assert tcontainer["a"] == 6 # wrong creation - assert raises(lambda: TestTContainer(a=0, b=1)) == TypeError + assert raises(lambda: TestTContainer(a=0, b=1)) is TypeError # type: ignore def test_dataclass_str_repr() -> None: @dataclasses.dataclass class Image(DataclassMixin): - signature: t.Optional[bytes] = csfield(cs.Const(b"BMP")) + signature: bytes = csfield(cs.Const(b"BMP")) width: int = csfield(cs.Int8ub) height: int = csfield(cs.Int8ub) format = DataclassStruct(Image) obj = Image(width=3, height=2) - assert ( - str(obj) - == "Image: \n signature = b'BMP' (total 3)\n width = 3\n height = 2" - ) + assert str(obj) == "Image: \n signature = b'BMP' (total 3)\n width = 3\n height = 2" obj = format.parse(format.build(obj)) - assert ( - str(obj) - == "Image: \n signature = b'BMP' (total 3)\n width = 3\n height = 2" - ) + assert str(obj) == "Image: \n signature = b'BMP' (total 3)\n width = 3\n height = 2" def test_dataclass_ifthenelse() -> None: @dataclasses.dataclass class IfThenElseTest(DataclassMixin): test_if: t.Optional[int] = csfield(cs.If(False, cs.Int8ub)) - test_ifthenelse: t.Optional[int] = csfield( - cs.IfThenElse(True, cs.Int8ub, cs.Pass) - ) + test_ifthenelse: t.Optional[int] = csfield(cs.IfThenElse(True, cs.Int8ub, cs.Pass)) a = IfThenElseTest(test_if=None, test_ifthenelse=None) - assert a.test_if == None - assert a.test_ifthenelse == None + assert a.test_if is None + assert a.test_ifthenelse is None def test_dataclass_struct() -> None: @@ -138,8 +172,8 @@ class InnerDataclass(DataclassMixin): common( DataclassStruct(TestContainer), - b"\x02\x01\xF1\xF2", - TestContainer(length=2, a=TestContainer.InnerDataclass(b=1, c=b"\xF1\xF2")), + b"\x02\x01\xf1\xf2", + TestContainer(length=2, a=TestContainer.InnerDataclass(b=1, c=b"\xf1\xf2")), ) @@ -148,11 +182,9 @@ def test_dataclass_struct_default_field() -> None: class Image(DataclassMixin): width: int = csfield(cs.Int8ub) height: int = csfield(cs.Int8ub) - pixels: t.Optional[bytes] = csfield( - cs.Default( - cs.Bytes(cs.this.width * cs.this.height), - lambda ctx: bytes(ctx.width * ctx.height), - ) + pixels: bytes = csdefault_field( + cs.Bytes(cs.this.width * cs.this.height), + lambda ctx: bytes(ctx.width * ctx.height), ) common( @@ -163,10 +195,25 @@ class Image(DataclassMixin): ) +def test_dataclass_struct_computed_field() -> None: + @dataclasses.dataclass + class Image(DataclassMixin): + width: int = csfield(cs.Int8ub) + height: int = csfield(cs.Int8ub) + size: int = csfield(cs.Computed(lambda ctx: ctx.width * ctx.height)) + + common( + DataclassStruct(Image), + b"\x02\x03", + setattrs(Image(2, 3), size=6), + 2, + ) + + def test_dataclass_struct_const_field() -> None: @dataclasses.dataclass class TestContainer(DataclassMixin): - const_field: t.Optional[bytes] = csfield(cs.Const(b"\x00")) + const_field: bytes = csfield(cs.Const(b"\x00")) common( DataclassStruct(TestContainer), @@ -200,14 +247,14 @@ class TestContainer(DataclassMixin): def test_dataclass_struct_anonymus_fields_1() -> None: @dataclasses.dataclass class TestContainer(DataclassMixin): - _1: t.Optional[bytes] = csfield(cs.Const(b"\x00")) + _1: bytes = csfield(cs.Const(b"\x00")) _2: None = csfield(cs.Padding(1)) _3: None = csfield(cs.Pass) _4: None = csfield(cs.Terminated) common( DataclassStruct(TestContainer), - bytes(2), + b"\x00\x00", setattrs(TestContainer(), _1=b"\x00"), cs.SizeofError, ) @@ -217,7 +264,7 @@ def test_dataclass_struct_anonymus_fields_2() -> None: @dataclasses.dataclass class TestContainer(DataclassMixin): _1: int = csfield(cs.Computed(7)) - _2: t.Optional[bytes] = csfield(cs.Const(b"JPEG")) + _2: bytes = csfield(cs.Const(b"JPEG")) _3: None = csfield(cs.Pass) _4: None = csfield(cs.Terminated) @@ -287,7 +334,7 @@ class TestContainer(DataclassMixin): a: int = csfield(cs.Int16ub) b: int = csfield(cs.Int8ub) - assert raises(lambda: DataclassStruct(TestContainer)) == TypeError + assert raises(lambda: DataclassStruct(TestContainer)) is TypeError def test_dataclass_struct_no_DataclassMixin() -> None: @@ -297,7 +344,7 @@ class TestContainer: b: int = csfield(cs.Int8ub) cls = t.cast(t.Type[DataclassMixin], TestContainer) - assert raises(lambda: DataclassStruct(cls)) == TypeError + assert raises(lambda: DataclassStruct(cls)) is TypeError def test_dataclass_struct_wrong_container() -> None: @@ -311,23 +358,18 @@ class TestContainer2(DataclassMixin): a: int = csfield(cs.Int16ub) b: int = csfield(cs.Int8ub) - assert ( - raises(DataclassStruct(TestContainer1).build, TestContainer2(a=1, b=2)) - == TypeError - ) + assert raises(DataclassStruct(TestContainer1).build, TestContainer2(a=1, b=2)) is TypeError def test_dataclass_struct_doc() -> None: @dataclasses.dataclass class TestContainer(DataclassMixin): - a: int = csfield(cs.Int16ub, "This is the documentation of a") - b: int = csfield( - cs.Int8ub, doc="This is the documentation of b\nwhich is multiline" - ) + a: int = csfield(cs.Int16ub, "This is the documentation of `a`") + b: int = csfield(cs.Int8ub, doc="This is the documentation of `b`\nwhich is multiline") c: int = csfield( cs.Int8ub, """ - This is the documentation of c + This is the documentation of `c` which is also multiline """, ) @@ -335,12 +377,9 @@ class TestContainer(DataclassMixin): format = DataclassStruct(TestContainer) common(format, b"\x00\x01\x02\x03", TestContainer(a=1, b=2, c=3), 4) - assert format.subcon.a.docs == "This is the documentation of a" - assert format.subcon.b.docs == "This is the documentation of b\nwhich is multiline" - assert ( - format.subcon.c.docs - == "This is the documentation of c\nwhich is also multiline" - ) + assert format.subcon.a.docs == "This is the documentation of `a`" + assert format.subcon.b.docs == "This is the documentation of `b`\nwhich is multiline" + assert format.subcon.c.docs == "This is the documentation of `c`\nwhich is also multiline" def test_dataclass_bitstruct() -> None: @@ -354,7 +393,7 @@ class TestContainer(DataclassMixin): common( DataclassBitStruct(TestContainer), - b"\xFD\x12", + b"\xfd\x12", TestContainer(a=0x7E, b=1, c=0x12), 2, ) @@ -386,7 +425,7 @@ class TestEnum(cst.EnumBase): assert d.parse(b"\xff") == TestEnum(255) assert d.parse(b"\xff") == 255 assert int(d.parse(b"\xff")) == 255 - assert raises(d.build, 8) == TypeError + assert raises(d.build, 8) is TypeError def test_tenum_no_enumbase() -> None: @@ -395,15 +434,11 @@ class E(enum.Enum): b = 2 cls = t.cast(t.Type[cst.EnumBase], E) - assert raises(lambda: cst.TEnum(cs.Byte, cls)) == TypeError + assert raises(lambda: cst.TEnum(cs.Byte, cls)) is TypeError def test_tenum_asdict() -> None: # see: https://github.com/timrid/construct-typing/issues/21 - import dataclasses - - import construct_typed as cst - class TestEnum(cst.EnumBase): one = 1 two = 2 @@ -470,7 +505,7 @@ class E2(cst.EnumBase): a = 1 b = 2 - assert raises(cst.TEnum(cs.Byte, E1).build, E2.a) == TypeError + assert raises(cst.TEnum(cs.Byte, E1).build, E2.a) is TypeError def test_tenum_in_tstruct() -> None: @@ -491,7 +526,7 @@ class TestContainer(DataclassMixin): ) assert ( - raises(cst.TEnum(cs.Byte, TestEnum).build, TestContainer(a=1, b=2)) == TypeError # type: ignore + raises(cst.TEnum(cs.Byte, TestEnum).build, TestContainer(a=1, b=2)) is TypeError # type: ignore ) @@ -510,14 +545,10 @@ class TestEnum(cst.FlagsEnumBase): assert d.build(TestEnum(1 | 2)) == b"\x03" assert d.build(TestEnum(255)) == b"\xff" assert d.build(TestEnum.eight) == b"\x08" - assert raises(d.build, 2) == TypeError + assert raises(d.build, 2) is TypeError def test_tenum_flags_asdict() -> None: - import dataclasses - - import construct_typed as cst - class TestEnum(cst.FlagsEnumBase): one = 1 two = 2 diff --git a/tests/test_typed_pyright.py b/tests/test_typed_pyright.py new file mode 100644 index 0000000..3b8536d --- /dev/null +++ b/tests/test_typed_pyright.py @@ -0,0 +1,50 @@ +# ############################################################################################################### +# Test if Pyright recognizes missing or extraneous construct parameters in dataclasses and would generate errors. +# We do this by ignoring the issue with `ignore[reportCallIssue]` while at the same time forcing Pyright to +# throw an error for unnecessary ignores. +# ############################################################################################################### + +# This rule is essential for these tests to work! +# pyright: reportUnnecessaryTypeIgnoreComment=error +import dataclasses +import typing as t + +import construct as cs + +from construct_typed import DataclassMixin, csdefault_field, csfield + + +def test_dataclass_const_default() -> None: + @dataclasses.dataclass + class ConstDefaultTest(DataclassMixin): + const_bytes: bytes = csfield(cs.Const(b"BMP")) + const_int: int = csfield(cs.Const(5, cs.Int8ub)) + default_int: int = csdefault_field(cs.Int8ub, 8) + default_lambda: bytes = csdefault_field(cs.Bytes(cs.this.default_int), lambda ctx: bytes(ctx.default_int)) + computed: bytes = csfield(cs.Computed(lambda ctx: bytes(i + 49 for i in range(ctx.default_int)))) + # Construct allows to put non-default values after Default. Dataclass and Pyright don't like that too much. It is necessary to + # specify the field `kw_only` and pass it "by keyword". + normal_int: int = csfield(cs.Int8ub, kw_only=True) + const_int2: int = csfield(cs.Const(5, cs.Int8ub)) + + if t.TYPE_CHECKING: + # Regression checks: MUST generate Pyright reportCallIssue or will trigger `reportUnnecessaryTypeIgnoreComment`. + ConstDefaultTest(const_bytes=b"", normal_int=7) # pyright: ignore[reportCallIssue] + ConstDefaultTest(const_int=0, normal_int=7) # pyright: ignore[reportCallIssue] + ConstDefaultTest(computed=bytes(), normal_int=7) # pyright: ignore[reportCallIssue] + + +def test_dataclass_padded() -> None: + @dataclasses.dataclass + class PaddingTest(DataclassMixin): + padding: t.Optional[bytes] = csfield(cs.Padding(1)) + padded_pass: t.Optional[bytes] = csfield(cs.Padded(2, cs.Pass)) + padded_bytes: bytes = csfield(cs.Padded(7, cs.Bytes(5))) + padded_string: str = csfield(cs.PaddedString(4, "utf-8")) + + if t.TYPE_CHECKING: + # Regression checks: MUST generate Pyright reportCallIssue or will trigger `reportUnnecessaryTypeIgnoreComment`. + PaddingTest(padding=b"\x00", padded_bytes=b"12345", padded_string="abc") # pyright: ignore[reportCallIssue] + PaddingTest(padded_pass=bytes(0), padded_bytes=b"12345", padded_string="abc") # pyright: ignore[reportCallIssue] + PaddingTest(padded_bytes=b"12345") # pyright: ignore[reportCallIssue] # padded_string missing + PaddingTest(padded_string="abc") # pyright: ignore[reportCallIssue] # padded_bytes missing