From 728115942859882fc306d06beb155e249cc8c099 Mon Sep 17 00:00:00 2001 From: "Robin.Schmidt" Date: Fri, 12 Jun 2026 13:43:19 +0200 Subject: [PATCH 1/8] Added `csfield` overloads to enable Pyright to detect missing/superfluous fields in construct definitions. --- construct-stubs/core.pyi | 11 +- construct_typed/dataclass_struct.py | 194 +++++++++++++++++++++------- tests/test_typed.py | 110 ++++++++++------ 3 files changed, 225 insertions(+), 90 deletions(-) diff --git a/construct-stubs/core.pyi b/construct-stubs/core.pyi index 456ab4a..dcc7e10 100644 --- a/construct-stubs/core.pyi +++ b/construct-stubs/core.pyi @@ -921,9 +921,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/dataclass_struct.py b/construct_typed/dataclass_struct.py index 6315857..3a20f2c 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,151 @@ ) 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, ParsedType, PathType +# Overload 1: Const → init=False (no __init__ parameter, has internal default) +@t.overload +def csfield( + subcon: "cs.Const[ParsedType, t.Any]", + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + init: t.Literal[False] = ..., +) -> ParsedType: ... + + +# Overload 2: Rebuild → init=False (variable ParsedType, no default) +@t.overload +def csfield( + subcon: "cs.Rebuild[ParsedType, t.Any]", + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + init: t.Literal[False] = ..., +) -> ParsedType: ... + + +# Overload 3: Computed → init=False (variable ParsedType, no default) +@t.overload +def csfield( + subcon: "cs.Computed[ParsedType]", + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + init: t.Literal[False] = ..., +) -> ParsedType: ... + + +# 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: Default → optional field but CALLER MUST SPECIFY default= explicitly! +# For example: +# ``` +# default_int: int = csfield(cs.Default(cs.Int8ub, 8), default=0) +# ``` +# The "second" `default=` value is used only for the typing system so that Pyright can match with this overload. +# The actual value doesn't matter but should match the `ParsedType`. +@t.overload +def csfield( + subcon: "cs.Default[ParsedType, t.Any]", + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + default: ParsedType = ..., +) -> ParsedType: ... + + +# Overload 8: all other ctors → mandatory field (no default) +@t.overload +def csfield( + subcon: Construct[ParsedType, t.Any], + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, +) -> ParsedType: ... + + +def csfield( + subcon: Construct[ParsedType, t.Any], + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + **_kwargs: t.Any, # absorbs `init=` and `default=` from overloads (only for type checkers) +) -> 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 = orig_subcon + default = const_subcon.value + elif isinstance(orig_subcon, cs.Default): + default_subcon = orig_subcon + # Default-Felder sind immer im __init__ (als optionaler Parameter mit Default), unabhängig von 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( + ParsedType, + dataclasses.field( + default=default, + init=init, + metadata={"subcon": subcon}, + ), + ) + + +@typing_extensions.dataclass_transform(field_specifiers=(csfield,)) class DataclassMixin: """ Mixin for the dataclasses which are passed to "DataclassStruct" and "DataclassBitStruct". @@ -74,52 +217,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 +248,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/tests/test_typed.py b/tests/test_typed.py index f6bcd2a..58e2a35 100644 --- a/tests/test_typed.py +++ b/tests/test_typed.py @@ -17,22 +17,69 @@ 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)) + default_int: int = csfield(cs.Default(cs.Int8ub, 8), default=0) + default_lambda: t.Optional[bytes] = csfield( + cs.Default(cs.Bytes(cs.this.default_int), lambda ctx: bytes(ctx.default_int)), + default=None, ) + computed: t.Optional[bytes] = csfield(cs.Computed(lambda ctx: bytes(i + 49 for i in range(ctx.default_int)))) - a = ConstDefaultTest() + format = DataclassStruct(ConstDefaultTest) + + a = ConstDefaultTest( + # const_bytes=b"", # adding this should trigger Pyright error (reportCallIssue) + # const_int=0, # adding this should trigger Pyright error (reportCallIssue) + # computed=bytes(), # adding this should trigger Pyright error (reportCallIssue) + ) 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" + 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", + ) + 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( + # padding=b"\x00", # adding this should trigger Pyright error (reportCallIssue) + # padded_pass=None, # adding this should trigger Pyright error (reportCallIssue) + padded_bytes=b"12345", # removing this should trigger Pyright error (reportCallIssue) + padded_string="abc", # removing this should trigger Pyright error (reportCallIssue) + ) + 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,36 +98,28 @@ 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)) == 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 @@ -138,8 +177,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 +187,12 @@ 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( + pixels: bytes = csfield( cs.Default( cs.Bytes(cs.this.width * cs.this.height), lambda ctx: bytes(ctx.width * ctx.height), - ) + ), + default=bytes(), ) common( @@ -166,7 +206,7 @@ class Image(DataclassMixin): 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,7 +240,7 @@ 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) @@ -217,7 +257,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) @@ -311,19 +351,14 @@ 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)) == 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" - ) + b: int = csfield(cs.Int8ub, doc="This is the documentation of b\nwhich is multiline") c: int = csfield( cs.Int8ub, """ @@ -337,10 +372,7 @@ class TestContainer(DataclassMixin): 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.c.docs == "This is the documentation of c\nwhich is also multiline" def test_dataclass_bitstruct() -> None: @@ -354,7 +386,7 @@ class TestContainer(DataclassMixin): common( DataclassBitStruct(TestContainer), - b"\xFD\x12", + b"\xfd\x12", TestContainer(a=0x7E, b=1, c=0x12), 2, ) From 082f5684a6a5dc3b589697e90c944dead5e660ad Mon Sep 17 00:00:00 2001 From: "Robin.Schmidt" Date: Mon, 15 Jun 2026 16:45:07 +0200 Subject: [PATCH 2/8] Introducing `csdefault_field()` Method to avoid duplicating `default=`. --- construct_typed/__init__.py | 2 + construct_typed/dataclass_struct.py | 69 ++++++++++++++++++++--------- tests/test_typed.py | 55 ++++++++++++++--------- 3 files changed, 86 insertions(+), 40 deletions(-) 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 3a20f2c..480741a 100644 --- a/construct_typed/dataclass_struct.py +++ b/construct_typed/dataclass_struct.py @@ -81,29 +81,14 @@ def csfield( ) -> None: ... -# Overload 7: Default → optional field but CALLER MUST SPECIFY default= explicitly! -# For example: -# ``` -# default_int: int = csfield(cs.Default(cs.Int8ub, 8), default=0) -# ``` -# The "second" `default=` value is used only for the typing system so that Pyright can match with this overload. -# The actual value doesn't matter but should match the `ParsedType`. -@t.overload -def csfield( - subcon: "cs.Default[ParsedType, t.Any]", - doc: t.Optional[str] = None, - parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, - *, - default: ParsedType = ..., -) -> ParsedType: ... - - -# Overload 8: all other ctors → mandatory field (no default) +# Overload 7: all other ctors → mandatory field (no default) @t.overload def csfield( subcon: Construct[ParsedType, t.Any], doc: t.Optional[str] = None, parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + kw_only: bool = ..., ) -> ParsedType: ... @@ -111,12 +96,19 @@ def csfield( subcon: Construct[ParsedType, t.Any], doc: t.Optional[str] = None, parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, - **_kwargs: t.Any, # absorbs `init=` and `default=` from overloads (only for type checkers) + *, + kw_only: bool = False, + **_kwargs: t.Any, # absorbs `init=` et al. from overloads (only for type checkers) ) -> 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. + However, to have proper Pyright support for Default, use `csdefault()` 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 @@ -139,7 +131,7 @@ def csfield( default = const_subcon.value elif isinstance(orig_subcon, cs.Default): default_subcon = orig_subcon - # Default-Felder sind immer im __init__ (als optionaler Parameter mit Default), unabhängig von flagbuildnone. + # 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 @@ -151,11 +143,48 @@ def csfield( dataclasses.field( default=default, init=init, + kw_only=kw_only, metadata={"subcon": subcon}, ), ) +def csdefault_field( + subcon: Construct[ParsedType, t.Any], + default: t.Union[ParsedType, t.Callable[[Context], 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. + + Use ONLY this method to ONLY process Default fields! + """ + cs_default = cs.Default(subcon, default) + + 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( + ParsedType, + 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: """ diff --git a/tests/test_typed.py b/tests/test_typed.py index 58e2a35..10f906b 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,12 +17,13 @@ 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, 8), default=0) - default_lambda: t.Optional[bytes] = csfield( - cs.Default(cs.Bytes(cs.this.default_int), lambda ctx: bytes(ctx.default_int)), - default=None, - ) + default_int: int = csdefault_field(cs.Int8ub, 8) + default_lambda: t.Optional[bytes] = csdefault_field(cs.Bytes(cs.this.default_int), lambda ctx: bytes(ctx.default_int)) computed: t.Optional[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) @@ -30,13 +31,14 @@ class ConstDefaultTest(DataclassMixin): # const_bytes=b"", # adding this should trigger Pyright error (reportCallIssue) # const_int=0, # adding this should trigger Pyright error (reportCallIssue) # computed=bytes(), # adding this should trigger Pyright error (reportCallIssue) + normal_int=7, ) assert a.const_bytes == b"BMP" assert a.const_int == 5 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" + 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) @@ -46,6 +48,7 @@ class ConstDefaultTest(DataclassMixin): b = ConstDefaultTest( default_int=4, default_lambda=b"TEST", + normal_int=1, ) b = format.parse(format.build(b)) assert b.default_int == 4 @@ -187,12 +190,9 @@ def test_dataclass_struct_default_field() -> None: class Image(DataclassMixin): width: int = csfield(cs.Int8ub) height: int = csfield(cs.Int8ub) - pixels: bytes = csfield( - cs.Default( - cs.Bytes(cs.this.width * cs.this.height), - lambda ctx: bytes(ctx.width * ctx.height), - ), - default=bytes(), + pixels: bytes = csdefault_field( + cs.Bytes(cs.this.width * cs.this.height), + lambda ctx: bytes(ctx.width * ctx.height), ) common( @@ -203,6 +203,21 @@ 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: bytes = 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): @@ -247,7 +262,7 @@ class TestContainer(DataclassMixin): common( DataclassStruct(TestContainer), - bytes(2), + b"\x00\x00", setattrs(TestContainer(), _1=b"\x00"), cs.SizeofError, ) @@ -357,12 +372,12 @@ class TestContainer2(DataclassMixin): 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 """, ) @@ -370,9 +385,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: From 1414d180f38a4310fd4670b13439a33884dbd37f Mon Sep 17 00:00:00 2001 From: "Robin.Schmidt" Date: Mon, 15 Jun 2026 17:27:39 +0200 Subject: [PATCH 3/8] Allow for explicit "default=" marker (useful for extensions) --- construct_typed/dataclass_struct.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/construct_typed/dataclass_struct.py b/construct_typed/dataclass_struct.py index 480741a..d7828d3 100644 --- a/construct_typed/dataclass_struct.py +++ b/construct_typed/dataclass_struct.py @@ -81,7 +81,18 @@ def csfield( ) -> None: ... -# Overload 7: all other ctors → mandatory field (no default) +# Overload 7: all other ctors with explicit default +@t.overload +def csfield( + subcon: Construct[ParsedType, t.Any], + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + default: ParsedType = ..., +) -> ParsedType: ... + + +# Overload 8: all other ctors → mandatory field (no default) @t.overload def csfield( subcon: Construct[ParsedType, t.Any], From 9653daa395fa52a137d6f51e80a47635490ff4ef Mon Sep 17 00:00:00 2001 From: "Robin.Schmidt" Date: Mon, 29 Jun 2026 10:47:38 +0200 Subject: [PATCH 4/8] Added Computed stub to allow for `my_int: int = cst.csfield(cs.Computed(lambda ctx: 50))` --- construct-stubs/core.pyi | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/construct-stubs/core.pyi b/construct-stubs/core.pyi index dcc7e10..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: t.Callable[[Context], ParsedType], + ) -> None: ... + @t.overload def __init__( self, - func: ConstantOrContextLambda2[ParsedType], + func: ParsedType, ) -> None: ... Index: Construct[int, t.Any] From 772ed85a4b605488360b863c8f12c46e960c0798 Mon Sep 17 00:00:00 2001 From: "Robin.Schmidt" Date: Wed, 15 Jul 2026 12:49:28 +0200 Subject: [PATCH 5/8] Enhanced mypy compatibility. --- construct_typed/dataclass_struct.py | 47 ++++++++++++++++------------- pyproject.toml | 18 ++++++----- tests/test_core.py | 13 ++++---- tests/test_typed.py | 10 +++--- 4 files changed, 47 insertions(+), 41 deletions(-) diff --git a/construct_typed/dataclass_struct.py b/construct_typed/dataclass_struct.py index d7828d3..70b62f3 100644 --- a/construct_typed/dataclass_struct.py +++ b/construct_typed/dataclass_struct.py @@ -12,40 +12,44 @@ ) from construct.lib.py3compat import bytestringtype, reprstring, unicodestringtype -from construct_typed.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[ParsedType, t.Any]", + 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] = ..., -) -> ParsedType: ... +) -> FieldType: ... # Overload 2: Rebuild → init=False (variable ParsedType, no default) @t.overload def csfield( - subcon: "cs.Rebuild[ParsedType, t.Any]", + 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] = ..., -) -> ParsedType: ... +) -> FieldType: ... # Overload 3: Computed → init=False (variable ParsedType, no default) @t.overload def csfield( - subcon: "cs.Computed[ParsedType]", + subcon: "cs.Computed[FieldType]", doc: t.Optional[str] = None, parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, *, init: t.Literal[False] = ..., -) -> ParsedType: ... +) -> FieldType: ... # Overload 4: Padded[None,None] = Padding() → init=False, returns None @@ -84,38 +88,38 @@ def csfield( # Overload 7: all other ctors with explicit default @t.overload def csfield( - subcon: Construct[ParsedType, t.Any], + subcon: Construct[FieldType, t.Any], doc: t.Optional[str] = None, parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, *, - default: ParsedType = ..., -) -> ParsedType: ... + default: FieldType = ..., +) -> FieldType: ... # Overload 8: all other ctors → mandatory field (no default) @t.overload def csfield( - subcon: Construct[ParsedType, t.Any], + subcon: Construct[FieldType, t.Any], doc: t.Optional[str] = None, parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, *, kw_only: bool = ..., -) -> ParsedType: ... +) -> FieldType: ... def csfield( - subcon: Construct[ParsedType, t.Any], + 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) -) -> ParsedType: +) -> FieldType: """ 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. - However, to have proper Pyright support for Default, use `csdefault()` instead. + 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 @@ -150,7 +154,7 @@ def csfield( default = default_subcon.value return t.cast( - ParsedType, + FieldType, dataclasses.field( default=default, init=init, @@ -161,11 +165,11 @@ def csfield( def csdefault_field( - subcon: Construct[ParsedType, t.Any], - default: t.Union[ParsedType, t.Callable[[Context], t.Any]], + 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, -) -> ParsedType: +) -> FieldType: """ Helper method for "DataclassStruct" and "DataclassBitStruct" to create the dataclass fields. @@ -173,6 +177,7 @@ def csdefault_field( """ 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") @@ -184,7 +189,7 @@ def csdefault_field( dc_default: t.Any = None if callable(default) else default return t.cast( - ParsedType, + FieldType, dataclasses.field( default=dc_default, init=True, diff --git a/pyproject.toml b/pyproject.toml index 439f996..f02d389 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" +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,11 @@ 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 + "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 ] [tool.poe.tasks.test] @@ -201,4 +203,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..cf058db 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 * @@ -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 10f906b..d962708 100644 --- a/tests/test_typed.py +++ b/tests/test_typed.py @@ -18,8 +18,8 @@ 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: t.Optional[bytes] = csdefault_field(cs.Bytes(cs.this.default_int), lambda ctx: bytes(ctx.default_int)) - computed: t.Optional[bytes] = csfield(cs.Computed(lambda ctx: bytes(i + 49 for i in range(ctx.default_int)))) + 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) @@ -59,8 +59,8 @@ class ConstDefaultTest(DataclassMixin): 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)) + padding: None = csfield(cs.Padding(1)) + padded_pass: None = 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")) @@ -208,7 +208,7 @@ def test_dataclass_struct_computed_field() -> None: class Image(DataclassMixin): width: int = csfield(cs.Int8ub) height: int = csfield(cs.Int8ub) - size: bytes = csfield(cs.Computed(lambda ctx: ctx.width * ctx.height)) + size: int = csfield(cs.Computed(lambda ctx: ctx.width * ctx.height)) common( DataclassStruct(Image), From 048ef4ced772bdc0d852d90d55fc617f64b4d868 Mon Sep 17 00:00:00 2001 From: "Robin.Schmidt" Date: Wed, 15 Jul 2026 13:00:06 +0200 Subject: [PATCH 6/8] Fixed pyright E712 "Avoid equality comparisons to `True`" and E721 "# Use `is` and `is not` for type comparisons, or `isinstance()` for isinstance checks" in Tests --- pyproject.toml | 2 -- tests/test_core.py | 44 ++++++++++++++++++++++---------------------- tests/test_typed.py | 22 +++++++++++----------- 3 files changed, 33 insertions(+), 35 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f02d389..a6d737a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -160,8 +160,6 @@ 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 = [ - "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 diff --git a/tests/test_core.py b/tests/test_core.py index cf058db..61f9b14 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -142,9 +142,9 @@ def test_formatfield_floats_randomized() -> None: 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.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 @@ -290,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) @@ -307,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 @@ -351,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) @@ -386,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 @@ -420,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({}) @@ -533,8 +533,8 @@ 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)) @@ -631,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: @@ -740,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"" @@ -794,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 @@ -843,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 @@ -932,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 diff --git a/tests/test_typed.py b/tests/test_typed.py index d962708..bfeacc5 100644 --- a/tests/test_typed.py +++ b/tests/test_typed.py @@ -101,7 +101,7 @@ class TestTContainer(DataclassMixin): assert tcontainer["a"] == 6 # wrong creation - assert raises(lambda: TestTContainer(a=0, b=1)) == TypeError # type: ignore + assert raises(lambda: TestTContainer(a=0, b=1)) is TypeError # type: ignore def test_dataclass_str_repr() -> None: @@ -125,8 +125,8 @@ class IfThenElseTest(DataclassMixin): 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: @@ -342,7 +342,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: @@ -352,7 +352,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: @@ -366,7 +366,7 @@ 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: @@ -433,7 +433,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: @@ -442,7 +442,7 @@ 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: @@ -517,7 +517,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: @@ -538,7 +538,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 ) @@ -557,7 +557,7 @@ 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: From c33c342c1127e6665a0cc7ad901cd4e0c421ac9b Mon Sep 17 00:00:00 2001 From: "Robin.Schmidt" Date: Wed, 15 Jul 2026 13:02:19 +0200 Subject: [PATCH 7/8] Fixed pyright E741 "Ambiguous variable name: `...`" in Tests --- pyproject.toml | 1 - tests/test_core.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a6d737a..8907763 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -160,7 +160,6 @@ 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 = [ - "E741", # Ambiguous variable name: `...` "F403", # `from ... import *` used; unable to detect undefined names "F405", # `...` may be undefined, or defined from star imports ] diff --git a/tests/test_core.py b/tests/test_core.py index 61f9b14..b2b905d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -541,14 +541,14 @@ def test_index() -> None: 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: From 3659260779419fd31692b8e572fea0c116b7a6af Mon Sep 17 00:00:00 2001 From: "Robin.Schmidt" Date: Wed, 15 Jul 2026 14:05:40 +0200 Subject: [PATCH 8/8] Added unittests to check that Pyright throws errors on missing or extraneous construct parameters. --- pyproject.toml | 2 +- tests/test_core.py | 14 +++++------ tests/test_typed.py | 22 +++------------- tests/test_typed_pyright.py | 50 +++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 27 deletions(-) create mode 100644 tests/test_typed_pyright.py diff --git a/pyproject.toml b/pyproject.toml index 8907763..af54bdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,7 +93,7 @@ strict = true warn_unused_ignores = false [[tool.mypy.overrides]] -module = "tests.test_typed" +module = ["tests.test_typed", "tests.test_typed_pyright"] disable_error_code = ["call-arg", "arg-type"] [tool.pyright] diff --git a/tests/test_core.py b/tests/test_core.py index b2b905d..6064a09 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -141,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") 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 + 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) diff --git a/tests/test_typed.py b/tests/test_typed.py index bfeacc5..9d21b47 100644 --- a/tests/test_typed.py +++ b/tests/test_typed.py @@ -28,9 +28,6 @@ class ConstDefaultTest(DataclassMixin): format = DataclassStruct(ConstDefaultTest) a = ConstDefaultTest( - # const_bytes=b"", # adding this should trigger Pyright error (reportCallIssue) - # const_int=0, # adding this should trigger Pyright error (reportCallIssue) - # computed=bytes(), # adding this should trigger Pyright error (reportCallIssue) normal_int=7, ) assert a.const_bytes == b"BMP" @@ -59,19 +56,14 @@ class ConstDefaultTest(DataclassMixin): def test_dataclass_padded() -> None: @dataclasses.dataclass class PaddingTest(DataclassMixin): - padding: None = csfield(cs.Padding(1)) - padded_pass: None = csfield(cs.Padded(2, cs.Pass)) + 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( - # padding=b"\x00", # adding this should trigger Pyright error (reportCallIssue) - # padded_pass=None, # adding this should trigger Pyright error (reportCallIssue) - padded_bytes=b"12345", # removing this should trigger Pyright error (reportCallIssue) - padded_string="abc", # removing this should trigger Pyright error (reportCallIssue) - ) + 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" @@ -447,10 +439,6 @@ class E(enum.Enum): 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 @@ -561,10 +549,6 @@ class TestEnum(cst.FlagsEnumBase): 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