Skip to content
Closed
19 changes: 15 additions & 4 deletions construct-stubs/core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions construct_typed/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
TContainerMixin,
TStruct,
TStructField,
csdefault_field,
csfield,
sfield,
)
Expand All @@ -30,6 +31,7 @@
"TContainerMixin",
"TStruct",
"TStructField",
"csdefault_field",
"csfield",
"sfield",
"EnumBase",
Expand Down
239 changes: 191 additions & 48 deletions construct_typed/dataclass_struct.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
import typing_extensions
import dataclasses
import textwrap
import typing as t
Expand All @@ -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".
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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],
Expand Down
15 changes: 7 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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"
Expand All @@ -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]
Expand Down Expand Up @@ -201,4 +200,4 @@ sequence = [
"lint",
"typecheck",
"test",
]
]
Loading
Loading