diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0115c57..6f55adb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -33,7 +33,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install ruff + python -m pip install ruff ty - name: Lint with Ruff on a basic set of rules run: | @@ -42,3 +42,7 @@ jobs: - name: Lint with Ruff on an extended ruleset but always return success run: | ruff check libdestruct --output-format=github --exit-zero + + - name: Type check with ty + run: | + ty check --output-format=github diff --git a/libdestruct/__init__.py b/libdestruct/__init__.py index c66dcc4..14bbf89 100644 --- a/libdestruct/__init__.py +++ b/libdestruct/__init__.py @@ -5,7 +5,7 @@ # try: # pragma: no cover - from rich.traceback import install + from rich.traceback import install # ty: ignore[unresolved-import] install() except ImportError: # pragma: no cover diff --git a/libdestruct/backing/fake_resolver.py b/libdestruct/backing/fake_resolver.py index 637e67f..2e7946a 100644 --- a/libdestruct/backing/fake_resolver.py +++ b/libdestruct/backing/fake_resolver.py @@ -6,13 +6,15 @@ from __future__ import annotations +from typing import Literal + from libdestruct.backing.resolver import Resolver class FakeResolver(Resolver): """A class that can resolve elements in a simulated memory storage.""" - def __init__(self: FakeResolver, memory: dict | None = None, address: int | None = 0, endianness: str = "little") -> None: + def __init__(self: FakeResolver, memory: dict | None = None, address: int | None = 0, endianness: Literal["little", "big"] = "little") -> None: """Initializes a basic fake resolver.""" self.memory = memory if memory is not None else {} self.address = address diff --git a/libdestruct/backing/memory_resolver.py b/libdestruct/backing/memory_resolver.py index 34c559d..4b90a8d 100644 --- a/libdestruct/backing/memory_resolver.py +++ b/libdestruct/backing/memory_resolver.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from libdestruct.backing.resolver import Resolver @@ -17,7 +17,7 @@ class MemoryResolver(Resolver): """A class that can resolve itself to a value in a referenced memory storage.""" - def __init__(self: MemoryResolver, memory: MutableSequence, address: int | None, endianness: str = "little") -> None: + def __init__(self: MemoryResolver, memory: MutableSequence, address: int | None, endianness: Literal["little", "big"] = "little") -> None: """Initializes a basic memory resolver.""" self.memory = memory self.address = address diff --git a/libdestruct/backing/resolver.py b/libdestruct/backing/resolver.py index 32612f9..fbeae16 100644 --- a/libdestruct/backing/resolver.py +++ b/libdestruct/backing/resolver.py @@ -7,6 +7,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from typing import Literal from typing_extensions import Self @@ -16,7 +17,7 @@ class Resolver(ABC): parent: Self - endianness: str = "little" + endianness: Literal["little", "big"] = "little" """The endianness of the data this resolver accesses.""" @abstractmethod diff --git a/libdestruct/c/c_integer_types.py b/libdestruct/c/c_integer_types.py index bf76989..2759348 100644 --- a/libdestruct/c/c_integer_types.py +++ b/libdestruct/c/c_integer_types.py @@ -28,6 +28,7 @@ def get(self: _c_integer) -> int: def to_bytes(self: _c_integer) -> bytes: """Return the serialized representation of the object.""" if self._frozen: + assert isinstance(self._frozen_value, int) return self._frozen_value.to_bytes(self.size, self.endianness, signed=self.signed) return self.resolver.resolve(self.size, 0) diff --git a/libdestruct/c/c_str.py b/libdestruct/c/c_str.py index 3895a31..207c45d 100644 --- a/libdestruct/c/c_str.py +++ b/libdestruct/c/c_str.py @@ -6,6 +6,8 @@ from __future__ import annotations +from collections.abc import Iterator + from libdestruct.common.array.array import array @@ -54,7 +56,7 @@ def __setitem__(self: c_str, index: int, value: bytes) -> None: """Set the character at the given index to the given value.""" self._set(value, index) - def __iter__(self: c_str) -> iter: + def __iter__(self: c_str) -> Iterator: """Return an iterator over the string.""" for i in range(self.count()): yield self.get(i) diff --git a/libdestruct/common/array/array.py b/libdestruct/common/array/array.py index e204302..72c3ad6 100644 --- a/libdestruct/common/array/array.py +++ b/libdestruct/common/array/array.py @@ -7,6 +7,7 @@ from __future__ import annotations from abc import abstractmethod +from collections.abc import Iterator from types import GenericAlias from libdestruct.common.obj import obj @@ -42,7 +43,7 @@ def __setitem__(self: array, index: int, value: object) -> None: self.set(index, value) @abstractmethod - def __iter__(self: array) -> iter: + def __iter__(self: array) -> Iterator: """Return an iterator over the array.""" def __contains__(self: array, value: object) -> bool: diff --git a/libdestruct/common/enum/enum.py b/libdestruct/common/enum/enum.py index 7aac7af..2a37eca 100644 --- a/libdestruct/common/enum/enum.py +++ b/libdestruct/common/enum/enum.py @@ -30,8 +30,8 @@ def __class_getitem__(cls, params: tuple) -> GenericAlias: python_enum: type[Enum] """The backing Python enum.""" - _backing_type: type[obj] - """The backing type.""" + _backing_type: obj + """The inflated backing instance.""" lenient: bool """Whether the conversion is lenient or not.""" diff --git a/libdestruct/common/enum/int_enum_field.py b/libdestruct/common/enum/int_enum_field.py index a9ead12..136af8b 100644 --- a/libdestruct/common/enum/int_enum_field.py +++ b/libdestruct/common/enum/int_enum_field.py @@ -58,7 +58,7 @@ def __init__( case _: raise ValueError("The size of the field must be a power of 2.") - def inflate(self: IntEnumField, resolver: Resolver) -> int: + def inflate(self: IntEnumField, resolver: Resolver) -> enum: """Inflate the field. Args: diff --git a/libdestruct/common/flags/flags.py b/libdestruct/common/flags/flags.py index eb324de..982b58e 100644 --- a/libdestruct/common/flags/flags.py +++ b/libdestruct/common/flags/flags.py @@ -30,8 +30,8 @@ def __class_getitem__(cls, params: tuple) -> GenericAlias: python_flag: type[IntFlag] """The backing Python IntFlag.""" - _backing_type: type[obj] - """The backing type.""" + _backing_type: obj + """The inflated backing instance.""" lenient: bool """Whether the conversion is lenient or not.""" diff --git a/libdestruct/common/inflater.py b/libdestruct/common/inflater.py index ff457c8..e34ed64 100644 --- a/libdestruct/common/inflater.py +++ b/libdestruct/common/inflater.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from libdestruct.backing.memory_resolver import MemoryResolver from libdestruct.common.type_registry import TypeRegistry @@ -21,7 +21,7 @@ class Inflater: """The memory manager, which inflates any memory-referencing type.""" - def __init__(self: Inflater, memory: MutableSequence, endianness: str = "little") -> None: + def __init__(self: Inflater, memory: MutableSequence, endianness: Literal["little", "big"] = "little") -> None: """Initialize the memory manager.""" self.memory = memory self.endianness = endianness diff --git a/libdestruct/common/obj.py b/libdestruct/common/obj.py index 4f59bbd..8e61001 100644 --- a/libdestruct/common/obj.py +++ b/libdestruct/common/obj.py @@ -7,7 +7,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import TYPE_CHECKING, Generic, Literal, TypeVar from libdestruct.common.hexdump import format_hexdump @@ -19,7 +19,7 @@ class obj(ABC, Generic[T]): """A generic object, with reference to the backing memory view.""" - endianness: str = "little" + endianness: Literal["little", "big"] = "little" """The endianness of the backing reference view.""" resolver: Resolver @@ -31,7 +31,7 @@ class obj(ABC, Generic[T]): _frozen_value: object = None """The frozen value of the object.""" - def __init__(self: obj, resolver: Resolver) -> None: + def __init__(self: obj, resolver: Resolver | None) -> None: """Initialize a generic object. Args: @@ -59,7 +59,7 @@ def to_bytes(self: obj) -> bytes: """Serialize the object to bytes.""" @classmethod - def from_bytes(cls: type[obj], data: bytes, endianness: str = "little") -> obj: + def from_bytes(cls: type[obj], data: bytes, endianness: Literal["little", "big"] = "little") -> obj: """Deserialize the object from bytes.""" from libdestruct.libdestruct import inflater diff --git a/libdestruct/common/ptr/ptr.py b/libdestruct/common/ptr/ptr.py index b550905..f884718 100644 --- a/libdestruct/common/ptr/ptr.py +++ b/libdestruct/common/ptr/ptr.py @@ -67,9 +67,10 @@ def get(self: ptr) -> int: value = self.resolver.resolve(self.size, 0) return int.from_bytes(value, self.endianness) - def to_bytes(self: obj) -> bytes: + def to_bytes(self: ptr) -> bytes: """Return the serialized representation of the object.""" if self._frozen: + assert isinstance(self._frozen_value, int) return self._frozen_value.to_bytes(self.size, self.endianness) return self.resolver.resolve(self.size, 0) @@ -159,7 +160,7 @@ def __sub__(self: ptr, n: int) -> ptr: new_addr = self.get() - n * self._element_size return ptr(_ArithmeticResolver(self.resolver, new_addr), self.wrapper) - def __getitem__(self: ptr, n: int) -> obj: + def __getitem__(self: ptr, n: int) -> obj | bytes: """Return the object at index n relative to this pointer.""" return (self + n).unwrap() diff --git a/libdestruct/common/struct/struct.py b/libdestruct/common/struct/struct.py index 2d812d6..ee57785 100644 --- a/libdestruct/common/struct/struct.py +++ b/libdestruct/common/struct/struct.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Literal from libdestruct.common.obj import obj from libdestruct.common.type_registry import TypeRegistry @@ -23,14 +23,14 @@ def __init__(self: struct) -> None: """Initialize the struct.""" raise RuntimeError("This type should not be directly instantiated.") - def __new__(cls: type[struct], *args: ..., **kwargs: ...) -> struct: # noqa: PYI034 + def __new__(cls: type[struct], *args: Any, **kwargs: Any) -> struct: # noqa: PYI034 """Create a new struct.""" # Look for an inflater for this struct type_impl = TypeRegistry().inflater_for(cls) return type_impl(*args, **kwargs) @classmethod - def from_bytes(cls: type[struct], data: bytes, endianness: str = "little") -> struct_impl: + def from_bytes(cls: type[struct], data: bytes, endianness: Literal["little", "big"] = "little") -> struct_impl: """Create a struct from a serialized representation.""" type_inflater = inflater(data, endianness=endianness) diff --git a/libdestruct/common/struct/struct_impl.py b/libdestruct/common/struct/struct_impl.py index 7ef8c02..f81f002 100644 --- a/libdestruct/common/struct/struct_impl.py +++ b/libdestruct/common/struct/struct_impl.py @@ -7,7 +7,7 @@ from __future__ import annotations from types import GenericAlias -from typing import Annotated, get_args, get_origin +from typing import Annotated, Any, get_args, get_origin from typing_extensions import Self @@ -37,7 +37,7 @@ class struct_impl(struct): _inflater: TypeRegistry = TypeRegistry() """The type registry, used for inflating the attributes.""" - def __init__(self: struct_impl, resolver: Resolver | None = None, **kwargs: ...) -> None: + def __init__(self: struct_impl, resolver: Resolver | None = None, **kwargs: Any) -> None: """Initialize the struct implementation.""" # If we have kwargs and the resolver is None, we provide a fake resolver if kwargs and resolver is None: @@ -80,7 +80,7 @@ def __setattr__(self: struct_impl, name: str, value: object) -> None: pass object.__setattr__(self, name, value) - def __new__(cls: struct_impl, *args: ..., **kwargs: ...) -> Self: + def __new__(cls: struct_impl, *args: Any, **kwargs: Any) -> Self: """Create a new struct.""" # Skip the __new__ method of the parent class # struct_impl -> struct -> obj becomes struct_impl -> obj diff --git a/libdestruct/common/union/union.py b/libdestruct/common/union/union.py index 9e73f04..2e9fc6e 100644 --- a/libdestruct/common/union/union.py +++ b/libdestruct/common/union/union.py @@ -94,7 +94,7 @@ def freeze(self: union) -> None: v.freeze() super().freeze() - def diff(self: union) -> tuple[object, object]: + def diff(self: union) -> tuple[object, object] | dict[str, tuple[object, object]]: """Return the difference between the frozen and current value.""" if self._variant is not None: return self._variant.diff() diff --git a/libdestruct/common/utils.py b/libdestruct/common/utils.py index d37836a..e365303 100644 --- a/libdestruct/common/utils.py +++ b/libdestruct/common/utils.py @@ -15,7 +15,7 @@ from libdestruct.common.type_registry import TypeRegistry if TYPE_CHECKING: # pragma: no cover - from collections.abc import Generator + from collections.abc import Callable, Generator from libdestruct.backing.resolver import Resolver from libdestruct.common.obj import obj @@ -26,7 +26,7 @@ def is_field_bound_method(item: obj) -> bool: return isinstance(item, MethodType) and isinstance(item.__self__, Field) -def size_of(item_or_inflater: obj | callable[[Resolver], obj]) -> int: +def size_of(item_or_inflater: obj | Callable[[Resolver], obj]) -> int: """Return the size in bytes of a type, instance, or field descriptor.""" # Field instances (e.g. array_of, ptr_to) — must come before .size check if isinstance(item_or_inflater, Field): diff --git a/libdestruct/libdestruct.py b/libdestruct/libdestruct.py index 04f05ee..22bbaab 100644 --- a/libdestruct/libdestruct.py +++ b/libdestruct/libdestruct.py @@ -9,7 +9,7 @@ import mmap from collections.abc import Sequence from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from typing_extensions import Self @@ -25,7 +25,7 @@ _VALID_ENDIANNESS = ("little", "big") -def inflater(memory: Sequence | mmap.mmap, endianness: str = "little") -> Inflater: +def inflater(memory: Sequence | mmap.mmap, endianness: Literal["little", "big"] = "little") -> Inflater: """Return a TypeInflater instance.""" if not isinstance(memory, Sequence | mmap.mmap): raise TypeError(f"memory must be a Sequence, not {type(memory).__name__}") @@ -43,7 +43,7 @@ def __init__( self: FileInflater, file_handle: io.BufferedReader, mmap_obj: mmap.mmap, - endianness: str = "little", + endianness: Literal["little", "big"] = "little", ) -> None: """Initialize the file-backed inflater.""" super().__init__(mmap_obj, endianness=endianness) @@ -60,7 +60,7 @@ def __exit__(self: FileInflater, *args: object) -> None: self._file_handle.close() -def inflater_from_file(path: str, writable: bool = False, endianness: str = "little") -> FileInflater: +def inflater_from_file(path: str, writable: bool = False, endianness: Literal["little", "big"] = "little") -> FileInflater: """Create an inflater backed by a memory-mapped file. Args: @@ -81,7 +81,7 @@ def inflater_from_file(path: str, writable: bool = False, endianness: str = "lit return FileInflater(file_handle, mmap_obj, endianness=endianness) -def inflate(item: type, memory: Sequence, address: int | Resolver, endianness: str = "little") -> obj: +def inflate(item: type, memory: Sequence, address: int | Resolver, endianness: Literal["little", "big"] = "little") -> obj: """Inflate a memory-referencing type. Args: diff --git a/pyproject.toml b/pyproject.toml index 06dd261..f4a6417 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,11 +34,38 @@ dependencies = ["typing_extensions", "pycparser"] [project.optional-dependencies] dev = [ "rich", + "ty", ] [tool.setuptools.packages.find] include = ["libdestruct*"] +[tool.ty.src] +exclude = ["test/", "build/"] + +[tool.ty.rules] +# -- Dynamic metaclass/descriptor inflation pattern -- +# The type registry, struct metaclass, and descriptor protocol resolve attributes +# at runtime in ways that ty cannot yet follow (_type_impl, _member_offsets, etc). +unresolved-attribute = "ignore" + +# -- Rules that caught real bugs -- +# Keep as warn until annotations are improved, then promote to error. +invalid-return-type = "warn" # union.diff() returns dict, ptr.__getitem__ returns bytes +invalid-argument-type = "warn" # union.__init__ passes None as Resolver +invalid-type-form = "warn" # lowercase `callable`, `iter` as type, `...` as annotation +missing-argument = "warn" # wrong _backing_type: type[obj] (should be obj) in enum/flags +too-many-positional-arguments = "warn" # array.__setitem__ calls set() with wrong arity + +# -- Intentional design patterns -- +invalid-method-override = "warn" # deliberate param narrowing in _set/set overrides (LSP) +invalid-assignment = "warn" # _backing_type instance vs type confusion; descriptor __set__ +unsupported-operator = "warn" # obj.get() returns `object`, operators on result untypeable +call-non-callable = "warn" # dynamic inflater dispatch: resolved_type(resolver) +not-subscriptable = "warn" # object.__setattr__ hides dict types from checker +no-matching-overload = "warn" # untyped dict in struct_parser +invalid-yield = "warn" # yield from dynamic __getitem__ typed as object + [tool.ruff] line-length = 120 indent-width = 4