diff --git a/CHANGELOG.md b/CHANGELOG.md index 69c808e..794842f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,18 @@ # Changelog ## [UNRELEASED] +**Breaking changes:** +- `csfield` should only be used for constructs that cannot build from `None`. Every other construct should use the new `csfield_noinit`, `csfield_const` or `csfield_default`. +- Removed wildcard imports, so it may be necessary to change some import statements in your code when you use non-public APIs. - Bumped minimum required Python version to 3.10 (previously: 3.9 which has reached end-of-life). - Removed `version.py`, use `importlib.metadata` instead to get the version number. -- Use `uv` as a project management tool and `poe` as a task runner. + +**New features:** +- Added `csfield_const` to create dataclass fields that are excluded from constructor and have a constant value. +- Added `csfield_default` to create dataclass fields that have a default value. +- Added `csfield_noinit` to create dataclass fields that are excluded from constructor. - Optimize type stubs for `Computed`, `Default` and `Rebuild` in conjunction with `ty`. -- Removed wildcard imports, so it may be necessary to change some import statements in your code when you use non-public APIs. \ No newline at end of file +- Optimize type stubs for `Checksum`, to represent that it can build from `None`. + +**Organizational changes:** +- Use `uv` as a project management tool and `poe` as a task runner. \ No newline at end of file diff --git a/README.md b/README.md index 5f11058..f82f936 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,8 @@ A short example: ```python import dataclasses import typing as t -from construct import Array, Byte, Const, Int8ub, this -from construct_typed import DataclassMixin, DataclassStruct, EnumBase, TEnum, csfield +from construct import Array, Byte, Bytes, Int8ub, this +from construct_typed import DataclassMixin, DataclassStruct, EnumBase, TEnum, csfield, csfield_const class Orientation(EnumBase): HORIZONTAL = 0 @@ -109,7 +109,7 @@ class Orientation(EnumBase): @dataclasses.dataclass class Image(DataclassMixin): - signature: bytes = csfield(Const(b"BMP")) + signature: bytes = csfield_const(Bytes(3), b"BMP") orientation: Orientation = csfield(TEnum(Int8ub, Orientation)) width: int = csfield(Int8ub) height: int = csfield(Int8ub) @@ -130,10 +130,10 @@ Output: b'BMP\x01\x03\x02\x07\x08\t\x0b\x0c\r' Image: signature = b'BMP' (total 3) - orientation = Orientation.VERTICAL + orientation = 1 width = 3 height = 2 - pixels = ListContainer: + pixels = ListContainer: 7 8 9 @@ -141,3 +141,91 @@ Image: 12 13 ``` + +#### Using constants in DataclassStruct +If you want a simple, fixed constant in a `DataclassStruct`, use `csfield_const`. It automatically wraps the given value in a `cs.Const` construct and sets the value directly, without a constructor parameter, when the `DataclassStruct` instance is created. + +```python +import dataclasses +from construct import Int8ub, Bytes +from construct_typed import DataclassMixin, csfield, csfield_const +import inspect + + +@dataclasses.dataclass +class Image(DataclassMixin): + signature: bytes = csfield_const(Bytes(3), b"BMP") # <-- no constructor parameter is generated + width: int = csfield(Int8ub) + height: int = csfield(Int8ub) + + +print(inspect.signature(Image)) # -> (width: int, height: int) -> None +``` + +#### Using defaults in DataclassStruct +If you want a simple, fixed default value for a parameter in a `DataclassStruct`, use `csfield_default`. It automatically wraps the given value in a `cs.Default` construct and sets it as the field's default value. This default value can still be overridden through the `DataclassStruct` constructor. As a consequence, all fields that follow it in the constructor must be marked as `kw_only`. + +```python +import dataclasses +from construct import Int8ub, Bytes +from construct_typed import DataclassMixin, csfield, csfield_default +import inspect + + +@dataclasses.dataclass +class Image(DataclassMixin): + some_value: int = csfield(Int8ub) + signature: bytes = csfield_default(Bytes(3), default=b"BMP") # <-- constructor parameter is generated with default value b"BMP" + width: int = csfield(Int8ub, kw_only=True) # <-- kw_only is required for all fields after a default field + height: int = csfield(Int8ub, kw_only=True) # <-- kw_only is required for all fields after a default field + + +print(inspect.signature(Image)) # -> (some_value: int, signature: bytes = b'BMP', *, width: int, height: int) -> None +``` + +For more complex cases, where the default value needs to be computed dynamically from the context, use `csfield_noinit(Default(...))` instead, since the context is not yet known when the `DataclassStruct` instance is created. For example: + +```python +import dataclasses +from construct import Int8ub, Int16ub, Default, this +from construct_typed import DataclassMixin, csfield, csfield_noinit +import inspect + + +@dataclasses.dataclass +class Image(DataclassMixin): + width: int = csfield(Int8ub) + height: int = csfield(Int8ub) + min_buffer_size: int | None = csfield_noinit( # <-- "| None" is required + Default(Int16ub, this.width * this.height) + ) + +print(inspect.signature(Image)) # -> (width: int, height: int) -> None +``` + + + +#### Using generic constructs that build from `None` +Some constructs, such as `cs.Computed`, `cs.Rebuild`, `cs.Padding`, `cs.Tell`, `cs.Pass` and `cs.Terminated`, are only computed dynamically at parse/build time. Fields for these constructs must be declared with `csfield_noinit`, because their values are not yet known when a `DataclassStruct` instance is created through the constructor, and are therefore automatically initialized to `None`. As a result, these fields cannot be overridden through the constructor. Because the constructor always sets them to `None`, their type must always be ` | None`. + +```python +import dataclasses +from construct import Int8ub, Computed +from construct.core import Tell +from construct_typed import DataclassMixin, csfield, csfield_noinit +import inspect + + +@dataclasses.dataclass +class Image(DataclassMixin): + width: int = csfield(Int8ub) + height: int = csfield(Int8ub) + pos: int | None = csfield_noinit(Tell) # <-- "| None" is required + size: int | None = csfield_noinit( # <-- "| None" is required + Computed(lambda ctx: ctx.width * ctx.height) + ) + +print(inspect.signature(Image)) # -> (width: int, height: int) -> None +``` + +Note: `csfield_noinit` can technically also be used with `cs.Const` or a non-lambda `cs.Default`, but the field then stays `None` until the struct is parsed, since the value is not assigned by the constructor. If the value should already be available right after construction, use `csfield_const()` or `csfield_default()` instead. \ No newline at end of file diff --git a/construct-stubs/core.pyi b/construct-stubs/core.pyi index 02277af..710de9e 100644 --- a/construct-stubs/core.pyi +++ b/construct-stubs/core.pyi @@ -1115,7 +1115,7 @@ class ProcessRotateLeft( T = t.TypeVar("T") -class Checksum(t.Generic[T, ParsedType, BuildTypes], Construct[ParsedType, BuildTypes]): +class Checksum(t.Generic[T, ParsedType, BuildTypes], Construct[ParsedType, t.Optional[BuildTypes]]): checksumfield: Construct[ParsedType, BuildTypes] hashfunc: t.Callable[[T], BuildTypes] bytesfunc: t.Callable[[Context], T] diff --git a/construct_typed/__init__.py b/construct_typed/__init__.py index d83e76e..4fd89e3 100644 --- a/construct_typed/__init__.py +++ b/construct_typed/__init__.py @@ -8,6 +8,9 @@ TStruct, TStructField, csfield, + csfield_const, + csfield_default, + csfield_noinit, sfield, ) from .generic_wrapper import ( @@ -30,7 +33,10 @@ "TContainerMixin", "TStruct", "TStructField", + "csfield_default", "csfield", + "csfield_noinit", + "csfield_const", "sfield", "EnumBase", "EnumValue", diff --git a/construct_typed/dataclass_struct.py b/construct_typed/dataclass_struct.py index 6315857..0a3951c 100644 --- a/construct_typed/dataclass_struct.py +++ b/construct_typed/dataclass_struct.py @@ -4,6 +4,7 @@ import typing as t import construct as cs +import typing_extensions from construct.lib.containers import ( globalPrintFullStrings, globalPrintPrivateEntries, @@ -11,9 +12,192 @@ ) from construct.lib.py3compat import bytestringtype, reprstring, unicodestringtype -from .generic_wrapper import Adapter, Construct, Context, ParsedType, PathType +from .generic_wrapper import Adapter, Construct, Context, PathType +T = t.TypeVar("T") + +class DataclassFieldError(Exception): + """Exception raised by csfield, csfield_noinit, csfield_const and csfield_default.""" + +def _rename_subcon( + subcon: Construct[T, t.Any], + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, +) -> Construct[T, t.Any]: + """Helper method to rename a 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) + return subcon + + +def csfield( + subcon: Construct[T, t.Any], + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + kw_only: bool = False, +) -> T: + """ + Field specifier for dataclasses that are passed to "DataclassStruct" and "DataclassBitStruct". + + Should not be used for fields that can be built from `None` (e.g. `cs.Default`, `cs.Const`, + `cs.Rebuild`, `cs.Computed`, `cs.Padding`, `cs.Tell`, `cs.Pass`, `cs.Terminated`). For these + fields, use `csfield_noinit()` instead, or - for `cs.Const` and `cs.Default` - + `csfield_const()` or `csfield_default()`. + + Note on `kw_only`: if a preceding field in the dataclass has a default value (e.g. created with + `csfield_default()`), every following field that has no default of its own (i.e. every plain + `csfield()`) must be marked `kw_only=True`. Otherwise Python's `dataclasses` module raises + ``TypeError: non-default argument '...' follows default argument`` when the class is defined. + """ + if subcon.flagbuildnone is True: + raise DataclassFieldError( + "Fields that can build from None, should be used with ``csfield_noinit()``, ``csfield_default()`` or ``csfield_const()``." + ) + + subcon = _rename_subcon(subcon, doc, parsed) + + return t.cast( + T, + dataclasses.field( + kw_only=kw_only, + metadata={"subcon": subcon}, + ), + ) + + +def csfield_noinit( + subcon: Construct[T, None], + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + # "init" should not be used by users. It is only for type checkers to see that this field is excluded from __init__. + init: t.Literal[False] = False, +) -> T | None: + """ + Field specifier for dataclasses that are passed to "DataclassStruct" and "DataclassBitStruct" for constructs that + can be build from `None` and thus should be *excluded* from the dataclass constructor. + + It can be used for e.g. `cs.Rebuild`, `cs.Computed`, `cs.Padding`, `cs.Tell`, `cs.Pass` and `cs.Terminated`. + + `cs.Const` and `cs.Default` can also be used with this field specifier. Note however that the field will then + be `None` until the struct is parsed, since the constant/default value is not applied by the constructor. If + the constant/default value should already be available right after construction, use `csfield_const()` or + `csfield_default()` instead. + """ + if subcon.flagbuildnone is False: + raise DataclassFieldError( + "csfield_noinit() can only be used with constructs that have flagbuildnone=True (Const, Rebuild, Computed, Padding, Tell, Pass, Terminated)." + ) + + subcon = _rename_subcon(subcon, doc, parsed) + + return t.cast( + T | None, + dataclasses.field( + default=None, + init=False, + metadata={"subcon": subcon}, + ), + ) + + +def csfield_const( + subcon: Construct[T, t.Any], + const: T, + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + # "init" should not be used by users. It is only for type checkers to see that this field is excluded from __init__. + init: t.Literal[False] = False, +) -> T: + """ + Field specifier for dataclasses that are passed to "DataclassStruct" and "DataclassBitStruct" for constants. + Fields that are created with this field specifier are *excluded* from the dataclass constructor. + + The subcon that is passed to this function is automatically wrapped in a `cs.Const` construct. + """ + if subcon.flagbuildnone is True: + raise DataclassFieldError( + "csfield_const() cannot be used with a subcon that can already build from None (e.g. another " + "``cs.Const``, ``cs.Default`` or ``cs.Computed``). Pass the plain, unwrapped subcon instead." + ) + if callable(const): + raise DataclassFieldError("csfield_const() cannot be used with context lambdas.") + if isinstance(subcon, cs.Const): + raise DataclassFieldError( + "csfield_const() cannot be used with a subcon that is already a ``cs.Const``. Pass the plain, unwrapped subcon instead." + ) + + subcon = cs.Const(const, subcon) + subcon = _rename_subcon(subcon, doc, parsed) + + return t.cast( + T, + dataclasses.field( + default=const, + init=False, + metadata={"subcon": subcon}, + ), + ) + + +def csfield_default( + subcon: Construct[T, t.Any], + doc: t.Optional[str] = None, + parsed: t.Optional[t.Callable[[t.Any, Context], None]] = None, + *, + default: T, +) -> T: + """ + Field specifier for dataclasses that are passed to "DataclassStruct" and + DataclassBitStruct" for fields with default values. Fields that are created + with this field specifier are *included* in the dataclass constructor. + + The subcon that is passed to this function is automatically wrapped in a + `cs.Default` construct. + + For default values that are calculated by the context, use + `csfield_noinit(cs.Default(...))` instead. + + Note: since this field has a default value, every field that follows it and has no default of its + own (i.e. every plain `csfield()`) must be marked `kw_only=True`. Otherwise Python's `dataclasses` + module raises ``TypeError: non-default argument '...' follows default argument`` when the class + is defined. + """ + if subcon.flagbuildnone is True: + raise DataclassFieldError( + "csfield_default() cannot be used with a subcon that can already build from None (e.g. " + "``cs.Const``, another ``cs.Default`` or ``cs.Computed``). Pass the plain, unwrapped subcon instead." + ) + if callable(default): + raise DataclassFieldError( + "csfield_default() cannot be used with context lambdas. Use `csfield_noinit(cs.Default(...))` instead." + ) + if isinstance(subcon, cs.Default): + raise DataclassFieldError( + "csfield_default() cannot be used with a subcon that is already a ``cs.Default``. Pass the plain, unwrapped subcon instead." + ) + + subcon = cs.Default(subcon, default) + subcon = _rename_subcon(subcon, doc, parsed) + + return t.cast( + T, + dataclasses.field( + default=default, + init=True, + metadata={"subcon": subcon}, + ), + ) + + +@typing_extensions.dataclass_transform( + field_specifiers=(csfield, csfield_noinit, csfield_const, csfield_default) +) class DataclassMixin: """ Mixin for the dataclasses which are passed to "DataclassStruct" and "DataclassBitStruct". @@ -74,52 +258,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 +289,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 1854bf3..4ba4fe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,12 @@ config-settings = { editable_mode = "compat" } [tool.mypy] strict = true warn_unused_ignores = false +warn_redundant_casts = false +# This file is specifically designed to test that Pyright emits errors for invalid dataclass calls +# (via `# pyright: ignore[reportCallIssue]`). Mypy doesn't understand pyright's ignore comments, so +# it reports these intentionally-invalid calls as real errors. Exclude it from mypy checking. +exclude = ["tests/test_typed_pyright.py"] + [tool.pyright] typeCheckingMode = "strict" @@ -111,9 +117,7 @@ exclude = [ reportInvalidTypeForm = "none" reportMissingParameterType = "none" reportUnknownArgumentType = "none" -reportUnknownMemberType = "none" reportUnknownParameterType = "none" -reportUnknownVariableType = "none" # These "unnecessary isinstance" checks often enhance readability or increase the future-proofness of the code, # since you can then explicitly "raise" for other types. So we don't want to complain about them. reportUnnecessaryIsInstance = "none" @@ -143,6 +147,9 @@ output-format = "concise" # "full" | "concise" | "grouped" | "json" | "junit" | [tool.ruff.lint] allowed-confusables = ["×", "–", "‘", "’"] +select = [ + "I" +] [tool.poe.tasks.test] cmd = "pytest $POE_EXTRA_ARGS" diff --git a/tests/test_typed.py b/tests/test_typed.py index e93e07b..258191f 100644 --- a/tests/test_typed.py +++ b/tests/test_typed.py @@ -1,38 +1,163 @@ # -*- coding: utf-8 -*- import dataclasses import enum +import hashlib import textwrap import typing as t import construct as cs import construct_typed as cst -from construct_typed import DataclassBitStruct, DataclassMixin, DataclassStruct, csfield +from construct_typed import ( + DataclassBitStruct, + DataclassMixin, + DataclassStruct, + csfield, + csfield_const, + csfield_default, + csfield_noinit, +) +from construct_typed.dataclass_struct import DataclassFieldError from .declarativeunittest import common, raises, setattrs -def test_dataclass_const_default() -> None: +def test_dataclass_const_default_noinit() -> 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 = csfield(cs.Default(cs.Int8ub, 28)) - default_lambda: bytes = csfield( - cs.Default(cs.Bytes(cs.this.const_int), lambda ctx: bytes(ctx.const_int)) + const_bytes: bytes = csfield_const(cs.Bytes(3), b"BMP") + const_int: int = csfield_const(cs.Int8ub, 5) + default_int: int = csfield_default(cs.Int8ub, default=8) + default_lambda: bytes | None = csfield_noinit( + cs.Default( + cs.Bytes(cs.this.default_int), lambda ctx: bytes(ctx.default_int) + ) + ) + computed: bytes | None = csfield_noinit( + 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_const(cs.Int8ub, 5) - a = ConstDefaultTest() + 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_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, + normal_int=1, + ) + b.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_csfield_validation_errors() -> None: + # `csfield` must reject subcons that can build from None. + assert ( + raises(lambda: csfield(cs.Const(b"BMP"))) is DataclassFieldError # type: ignore + ) + + # `csfield_noinit` must reject subcons that cannot build from None. + assert raises(lambda: csfield_noinit(cs.Int8ub)) is DataclassFieldError # type: ignore + + # `csfield_const`/`csfield_default` must reject subcons that already can build + # from None (e.g. double-wrapping another `Const`/`Default`/`Computed`). + assert ( + raises(lambda: csfield_const(cs.Const(b"BMP"), b"BMP")) is DataclassFieldError # type: ignore + ) + assert ( + raises(lambda: csfield_default(cs.Computed(1), default=1)) + is DataclassFieldError # type: ignore + ) + + # `csfield_const`/`csfield_default` must reject context lambdas. + assert ( + raises(lambda: csfield_const(cs.Int8ub, lambda ctx: 1)) is DataclassFieldError # type: ignore + ) + assert ( + raises(lambda: csfield_default(cs.Int8ub, default=lambda ctx: 1)) # type: ignore + is DataclassFieldError + ) + + # `csfield_const`/`csfield_default` must reject subcons that are already a `Const`/`Default`. + assert ( + raises(lambda: csfield_const(cs.Const(b"BMP"), b"BMP")) # type: ignore + is DataclassFieldError + ) + assert ( + raises(lambda: csfield_default(cs.Default(cs.Int8ub, 1), default=1)) # type: ignore + is DataclassFieldError + ) + + +def test_csfield_default_kw_only_ordering() -> None: + # If a field with a default (created via `csfield_default()`) is followed by a plain `csfield()` + # without `kw_only=True`, Python's own dataclass machinery must reject the class definition. + # `dataclasses.make_dataclass()` is used here (instead of a `class` statement) so that static type + # checkers - which also correctly flag this as an error - don't fail the type-check of this test file. + def build_bad_class() -> None: + dataclasses.make_dataclass( + "BadOrdering", + [ + ("a", int, csfield_default(cs.Int8ub, default=1)), + ("b", int, csfield(cs.Int8ub)), # missing kw_only=True + ], + bases=(DataclassMixin,), + ) + + assert raises(build_bad_class) is TypeError + + # Marking the following field as `kw_only=True` fixes the ordering issue. + @dataclasses.dataclass + class GoodOrdering(DataclassMixin): + a: int = csfield_default(cs.Int8ub, default=1) + b: int = csfield(cs.Int8ub, kw_only=True) + + obj = GoodOrdering(b=2) + assert obj.a == 1 + assert obj.b == 2 + + +def test_dataclass_padded() -> None: + @dataclasses.dataclass + class PaddingTest(DataclassMixin): + padding: bytes | None = csfield_noinit(cs.Padding(1)) + padded_pass: bytes | None = csfield_noinit(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_const(cs.Byte, 1) b: int = csfield(cs.Int8ub) tcontainer = TestTContainer(b=2) @@ -51,13 +176,13 @@ class TestTContainer(DataclassMixin): assert tcontainer["a"] == 6 # wrong creation - assert raises(lambda: TestTContainer(a=0, b=1)) is 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_const(cs.Bytes(3), b"BMP") width: int = csfield(cs.Int8ub) height: int = csfield(cs.Int8ub) @@ -148,7 +273,7 @@ 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 | None = csfield_noinit( cs.Default( cs.Bytes(cs.this.width * cs.this.height), lambda ctx: bytes(ctx.width * ctx.height), @@ -163,10 +288,65 @@ 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 | None = csfield_noinit( + 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_rebuild_field() -> None: + @dataclasses.dataclass + class Image(DataclassMixin): + width: int = csfield(cs.Int8ub) + height: int = csfield(cs.Int8ub) + size: int | None = csfield_noinit( + cs.Rebuild(cs.Int8ub, cs.this.width * cs.this.height) + ) + + common( + DataclassStruct(Image), + b"\x02\x03\x06", + setattrs(Image(2, 3), size=6), + 3, + ) + + +def test_dataclass_struct_checksum_field() -> None: + @dataclasses.dataclass + class Image(DataclassMixin): + width: int = csfield(cs.Int8ub) + height: int = csfield(cs.Int8ub) + checksum: bytes | None = csfield_noinit( + cs.Checksum( + cs.Bytes(4), + lambda data: hashlib.sha256(data).digest()[:4], + lambda ctx: bytes([ctx.width, ctx.height]), + ) + ) + + common( + DataclassStruct(Image), + b"\x02\x03\xee\x90\x40\xf6", + setattrs(Image(2, 3), checksum=b"\xee\x90\x40\xf6"), + 6, + ) + + 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 | None = csfield_noinit(cs.Const(b"\x00")) common( DataclassStruct(TestContainer), @@ -200,14 +380,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")) - _2: None = csfield(cs.Padding(1)) - _3: None = csfield(cs.Pass) - _4: None = csfield(cs.Terminated) + _1: bytes = csfield_const(cs.Bytes(1), b"\x00") + _2: None = csfield_noinit(cs.Padding(1)) + _3: None = csfield_noinit(cs.Pass) + _4: None = csfield_noinit(cs.Terminated) common( DataclassStruct(TestContainer), - bytes(2), + b"\x00\x00", setattrs(TestContainer(), _1=b"\x00"), cs.SizeofError, ) @@ -216,10 +396,10 @@ class TestContainer(DataclassMixin): 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")) - _3: None = csfield(cs.Pass) - _4: None = csfield(cs.Terminated) + _1: int | None = csfield_noinit(cs.Computed(7)) + _2: bytes = csfield_const(cs.Bytes(4), b"JPEG") + _3: None = csfield_noinit(cs.Pass) + _4: None = csfield_noinit(cs.Terminated) d = DataclassStruct(TestContainer) assert d.build(TestContainer()) == d.build(TestContainer()) @@ -400,10 +580,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 @@ -514,10 +690,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..0e3db94 --- /dev/null +++ b/tests/test_typed_pyright.py @@ -0,0 +1,56 @@ +# ############################################################################################################### +# 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, + csfield, + csfield_const, + csfield_default, + csfield_noinit, +) + + +def test_dataclass_const_default() -> None: + @dataclasses.dataclass + class ConstDefaultTest(DataclassMixin): + const_bytes: bytes = csfield_const(cs.Bytes(3), b"BMP") + const_int: int = csfield_const(cs.Int8ub, 5) + default_int: int = csfield_default(cs.Int8ub, default=8) + default_lambda: bytes | None = csfield_noinit(cs.Default(cs.Bytes(cs.this.default_int), lambda ctx: bytes(ctx.default_int))) + computed: bytes | None = csfield_noinit(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 | None = csfield_noinit(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_noinit(cs.Padding(1)) + padded_pass: t.Optional[bytes] = csfield_noinit(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