Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create a tool for stubs in cstruct v4 #72

Open
wants to merge 33 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e31f5f7
Adding stub creation tooling
Miauwkeru Feb 15, 2024
bd7cc39
Add stub information on types
Miauwkeru Feb 27, 2024
a02f8cd
Fix linting issues
Miauwkeru Feb 27, 2024
49cfebd
remove unused argument
Miauwkeru Feb 27, 2024
4579cf0
Not using a wildcard for the types
Miauwkeru Aug 9, 2024
7277b4c
Cleanup and move typedefs at the start of the file
Miauwkeru Aug 9, 2024
862e084
Add suggestion for stubify tool
Miauwkeru Aug 12, 2024
e23aebe
Move stub functions from cstruct to stubify.py
Miauwkeru Aug 12, 2024
92ebbbf
Improve the description
Miauwkeru Aug 12, 2024
737edd7
rename `to_stub` to `to_type_stub`
Miauwkeru Aug 12, 2024
deb8272
Add suggestions
Miauwkeru Aug 12, 2024
6a2ad74
Rename tool to cstruct-stubify
Miauwkeru Feb 11, 2025
6a7fa70
Change logging setup
Miauwkeru Feb 11, 2025
f6a0cbf
Move away from using io.StringIO
Miauwkeru Feb 11, 2025
d0c66d6
Add the variable name to cstruct to use them to improve typing.
Miauwkeru Feb 11, 2025
2f29215
Add underscore to names of named structure definitions. so there is n…
Miauwkeru Feb 11, 2025
526dd6f
Add typing info to Packed and improve Pointer typing
Miauwkeru Feb 11, 2025
354ee67
Improve test params
Miauwkeru Feb 11, 2025
d75ae02
Avoid adding the field names of anonymous structures/unions to the __…
Miauwkeru Feb 11, 2025
b555872
Add a hint that you can also use binaryio/bytes/bytearrays for initia…
Miauwkeru Feb 11, 2025
d2069c8
Add forgotten underscore kwarg in Pointer
Miauwkeru Feb 11, 2025
7d56ecf
Fix tests
Miauwkeru Feb 11, 2025
14ece45
Move test_stub to test_stubify_functions
Miauwkeru Feb 11, 2025
b4f85dd
Add more tests for stubify tool
Miauwkeru Feb 11, 2025
d0f823a
Add correct type path so there is no confusing when importing files
Miauwkeru Feb 11, 2025
c0dd94c
Remove unnused import
Miauwkeru Feb 11, 2025
451976b
Sort the top imports
Miauwkeru Feb 13, 2025
daf8a22
Add other signatures
Miauwkeru Feb 13, 2025
75730e6
Add additional signatures to the stub file
Miauwkeru Feb 13, 2025
881d966
Fix issue with stub file
Miauwkeru Feb 13, 2025
f602d6b
Fix linting
Miauwkeru Feb 13, 2025
451a8a5
Ignore stub file during linting
Miauwkeru Feb 13, 2025
590fc56
Fix linting issues
Miauwkeru Feb 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
184 changes: 184 additions & 0 deletions dissect/cstruct/tools/stubify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
from __future__ import annotations

import importlib
import importlib.util
import logging
from argparse import ArgumentParser
from pathlib import Path
from textwrap import indent
from types import FunctionType, ModuleType
from typing import TYPE_CHECKING, Any

import dissect.cstruct.types as types
from dissect.cstruct import cstruct

if TYPE_CHECKING:
from collections.abc import Iterable

Check warning on line 16 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L16

Added line #L16 was not covered by tests


log = logging.getLogger(__name__)


def load_module(path: Path, base_path: Path) -> ModuleType | None:
module = None
try:
relative_path = path.relative_to(base_path)
module_tuple = (*relative_path.parent.parts, relative_path.stem)
spec = importlib.util.spec_from_file_location(".".join(module_tuple), path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
except Exception as e:
log.exception("Unable to import %s", path)
log.debug("Error while trying to import module %s", path, exc_info=e)

return module


def to_type(_type: type | Any) -> type:
if not isinstance(_type, type):
_type = type(_type)
return _type

Check warning on line 40 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L38-L40

Added lines #L38 - L40 were not covered by tests


def stubify_file(path: Path, base_path: Path) -> str:
tmp_module = load_module(path, base_path)
if tmp_module is None:
return ""

Check warning on line 46 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L46

Added line #L46 was not covered by tests

if not hasattr(tmp_module, "cstruct"):
return ""

all_types = types.__all__.copy()
all_types.sort()
all_types.append("")

cstruct_types = indent(",\n".join(all_types), prefix=" " * 4)
result = [
"from __future__ import annotations\n",
"from typing import overload, BinaryIO\n",
"from typing_extensions import TypeAlias\n",
"from dissect.cstruct import cstruct",
f"from dissect.cstruct.types import (\n{cstruct_types})\n",
]

prev_entries = len(result)

for name, variable in tmp_module.__dict__.items():
if name.startswith("__"):
continue

if isinstance(variable, ModuleType):
result.append(f"import {name}")

Check warning on line 71 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L71

Added line #L71 was not covered by tests
elif isinstance(variable, cstruct):
result.append(stubify_cstruct(variable, name))
elif isinstance(variable, (bytes, bytearray, str, int, float, dict, list, tuple)):
result.append(f"{name}: {type(variable).__name__}")
elif isinstance(variable, FunctionType):
anno = variable.__annotations__
_items = list(anno.items())[:-1]

Check warning on line 78 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L77-L78

Added lines #L77 - L78 were not covered by tests

args = ", ".join(f"{name}: {to_type(_type).__name__}" for (name, _type) in _items)

Check warning on line 80 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L80

Added line #L80 was not covered by tests

return_value = repr(to_type(anno.get("return")).__name__)
signature = f"def {name}({''.join(args)}) -> {return_value}:"
if variable.__doc__:
result.append(signature)
result.append(f' """{variable.__doc__}"""')
result.append(" ...")

Check warning on line 87 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L82-L87

Added lines #L82 - L87 were not covered by tests
else:
result.append(f"{signature} ...")

Check warning on line 89 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L89

Added line #L89 was not covered by tests
elif "dissect.cstruct" in variable.__module__:
if hasattr(variable, "cs"):
result.append(f"{name}: {variable.cs.__type_def_name__}.{variable.__name__}")
elif isinstance(variable, type):
result.append(f"from {variable.__module__} import {name}")

Check warning on line 94 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L92-L94

Added lines #L92 - L94 were not covered by tests
else:
result.append(f"{name}: {variable.__class__.__name__}")

Check warning on line 96 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L96

Added line #L96 was not covered by tests

if prev_entries == len(result):
return ""

Check warning on line 99 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L99

Added line #L99 was not covered by tests

# Empty line at the end of the file
result.append("")
return "\n".join(result)


def stubify_cstruct(c_structure: cstruct, name: str = "", ignore_type_defs: Iterable[str] | None = None) -> str:
ignore_type_defs = ignore_type_defs or []

result = []
indentation = ""
if name:
result.append(f"class {name}(cstruct):")
indentation = " " * 4
c_structure.__type_def_name__ = name

prev_length = len(result)
for const, value in c_structure.consts.items():
result.append(indent(f"{const}: {type(value).__name__} = ...", prefix=indentation))

if type_defs := stubify_typedefs(c_structure, ignore_type_defs, indentation):
result.append(type_defs)

if prev_length == len(result):
# an empty definition, add elipses
result.append(indent("...", prefix=indentation))

return "\n".join(result)


def stubify_typedefs(c_structure: cstruct, ignore_type_defs: Iterable[str] | None = None, indentation: str = "") -> str:
ignore_type_defs = ignore_type_defs or []

result = []
for name, type_def in c_structure.typedefs.items():
if name in ignore_type_defs:
continue

if isinstance(type_def, types.MetaType) and (text := type_def.to_type_stub(name)):
result.append(indent(text, prefix=indentation))

return "\n".join(result)


def setup_logger(verbosity: int) -> None:
level = logging.INFO
if verbosity >= 1:
level = logging.DEBUG

Check warning on line 147 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L145-L147

Added lines #L145 - L147 were not covered by tests

logging.basicConfig(level=level)

Check warning on line 149 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L149

Added line #L149 was not covered by tests


def main() -> None:
description = """

Check warning on line 153 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L153

Added line #L153 was not covered by tests
Create stub files for cstruct definitions.

These stub files are in a `.pyi` format and provides type information to cstruct definitions.
"""

parser = ArgumentParser("stubify", description=description)
parser.add_argument("path", type=Path)
parser.add_argument("-v", "--verbose", action="count", default=0)
args = parser.parse_args()

Check warning on line 162 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L159-L162

Added lines #L159 - L162 were not covered by tests

setup_logger(args.verbose)

Check warning on line 164 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L164

Added line #L164 was not covered by tests

file_path: Path = args.path

Check warning on line 166 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L166

Added line #L166 was not covered by tests

iterator = file_path.rglob("*.py")
if file_path.is_file():
iterator = [file_path]

Check warning on line 170 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L168-L170

Added lines #L168 - L170 were not covered by tests

for file in iterator:
if file.is_file() and file.suffix == ".py":
stub = stubify_file(file, file_path)
if not stub:
continue

Check warning on line 176 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L172-L176

Added lines #L172 - L176 were not covered by tests

with file.with_suffix(".pyi").open("wt") as output_file:
log.info("Writing stub of file %s to %s", file, output_file.name)
output_file.write(stub)

Check warning on line 180 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L178-L180

Added lines #L178 - L180 were not covered by tests


if __name__ == "__main__":
main()

Check warning on line 184 in dissect/cstruct/tools/stubify.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/tools/stubify.py#L184

Added line #L184 was not covered by tests
33 changes: 33 additions & 0 deletions dissect/cstruct/types/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,22 @@
"""
return cls._write_array(stream, [*array, cls.__default__()])

def _class_stub(cls) -> str:
return f"class {cls.__name__}({cls.__base__.__name__}):"

Check warning on line 182 in dissect/cstruct/types/base.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/types/base.py#L182

Added line #L182 was not covered by tests

def _type_stub(cls, name: str = "", underscore: bool = False) -> str:
cls_name = cls.__name__
if underscore:
cls_name = f"_{cls_name}"

Check warning on line 187 in dissect/cstruct/types/base.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/types/base.py#L187

Added line #L187 was not covered by tests

if cls.__name__ in cls.cs.typedefs and (cs_name := getattr(cls.cs, "__type_def_name__", "")):
cls_name = f"{cs_name}.{cls_name}"

return f"{name}: {cls_name}"

def to_type_stub(cls, name: str) -> str:
return ""


class _overload:
"""Descriptor to use on the ``write`` and ``dumps`` methods on cstruct types.
Expand Down Expand Up @@ -244,6 +260,14 @@

return cls.type._read_array(stream, num, context)

def default(cls) -> BaseType:
return type.__call__(

Check warning on line 264 in dissect/cstruct/types/base.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/types/base.py#L264

Added line #L264 was not covered by tests
cls, [cls.type.default() for _ in range(0 if cls.dynamic or cls.null_terminated else cls.num_entries)]
)

def _type_stub(cls, name: str = "", underscore: bool = False) -> str:
return f"{name}: {cls.__base__.__name__}"


class Array(list, BaseType, metaclass=ArrayMetaType):
"""Implements a fixed or dynamically sized array type.
Expand All @@ -270,6 +294,15 @@

return cls.type._write_array(stream, data)

@classmethod
def _type_stub(cls, name: str = "", underscore: bool = False) -> str:
cls_name = cls.type.__name__

if cls_name in cls.cs.typedefs and (cs_name := getattr(cls.cs, "__type_def_name__", "")):
cls_name = f"{cs_name}.{cls_name}"

Check warning on line 302 in dissect/cstruct/types/base.py

View check run for this annotation

Codecov / codecov/patch

dissect/cstruct/types/base.py#L302

Added line #L302 was not covered by tests

return f"{name}: {cls.__base__.__name__}[{cls_name}]"


def _is_readable_type(value: Any) -> bool:
return hasattr(value, "read")
Expand Down
9 changes: 9 additions & 0 deletions dissect/cstruct/types/enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ def _write_0(cls, stream: BinaryIO, array: list[BaseType]) -> int:
data = [entry.value if isinstance(entry, Enum) else entry for entry in array]
return cls._write_array(stream, [*data, cls.type.__default__()])

def _class_stub(cls) -> str:
return f"class {cls.__name__}({cls.__base__.__name__}, {cls.type.__name__}):"

def to_type_stub(cls, name: str = "") -> str:
result = [cls._class_stub()]
result.extend(f" {key} = ..." for key in cls.__members__)

return "\n".join(result)


def _fix_alias_members(cls: type[Enum]) -> None:
# Emulate aenum NoAlias behaviour
Expand Down
21 changes: 14 additions & 7 deletions dissect/cstruct/types/packed.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from functools import lru_cache
from struct import Struct
from typing import Any, BinaryIO
from typing import Any, BinaryIO, Generic, TypeVar

from dissect.cstruct.types.base import EOF, BaseType

Expand All @@ -12,17 +12,20 @@ def _struct(endian: str, packchar: str) -> Struct:
return Struct(f"{endian}{packchar}")


class Packed(BaseType):
T = TypeVar("T", int, float)


class Packed(BaseType, Generic[T]):
"""Packed type for Python struct (un)packing."""

packchar: str

@classmethod
def _read(cls, stream: BinaryIO, context: dict[str, Any] | None = None) -> Packed:
def _read(cls, stream: BinaryIO, context: dict[str, Any] | None = None) -> Packed[T]:
return cls._read_array(stream, 1, context)[0]

@classmethod
def _read_array(cls, stream: BinaryIO, count: int, context: dict[str, Any] | None = None) -> list[Packed]:
def _read_array(cls, stream: BinaryIO, count: int, context: dict[str, Any] | None = None) -> list[Packed[T]]:
if count == EOF:
data = stream.read()
length = len(data)
Expand All @@ -39,7 +42,7 @@ def _read_array(cls, stream: BinaryIO, count: int, context: dict[str, Any] | Non
return [cls.__new__(cls, value) for value in fmt.unpack(data)]

@classmethod
def _read_0(cls, stream: BinaryIO, context: dict[str, Any] | None = None) -> Packed:
def _read_0(cls, stream: BinaryIO, context: dict[str, Any] | None = None) -> Packed[T]:
result = []

fmt = _struct(cls.cs.endian, cls.packchar)
Expand All @@ -57,9 +60,13 @@ def _read_0(cls, stream: BinaryIO, context: dict[str, Any] | None = None) -> Pac
return result

@classmethod
def _write(cls, stream: BinaryIO, data: Packed) -> int:
def _write(cls, stream: BinaryIO, data: Packed[T]) -> int:
return stream.write(_struct(cls.cs.endian, cls.packchar).pack(data))

@classmethod
def _write_array(cls, stream: BinaryIO, data: list[Packed]) -> int:
def _write_array(cls, stream: BinaryIO, data: list[Packed[T]]) -> int:
return stream.write(_struct(cls.cs.endian, f"{len(data)}{cls.packchar}").pack(*data))

@classmethod
def to_type_stub(cls, name: str) -> str:
return f"{name}: TypeAlias = Packed[{cls.__base__.__name__}]"
20 changes: 13 additions & 7 deletions dissect/cstruct/types/pointer.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
from __future__ import annotations

from typing import Any, BinaryIO
from typing import Any, BinaryIO, Generic, TypeVar

from dissect.cstruct.exceptions import NullPointerDereference
from dissect.cstruct.types.base import BaseType, MetaType
from dissect.cstruct.types.char import Char
from dissect.cstruct.types.void import Void

T = TypeVar("T", bound=MetaType)

class Pointer(int, BaseType):

class Pointer(int, BaseType, Generic[T]):
"""Pointer to some other type."""

type: MetaType
type: T
_stream: BinaryIO | None
_context: dict[str, Any] | None
_value: BaseType

def __new__(cls, value: int, stream: BinaryIO | None, context: dict[str, Any] | None = None) -> Pointer: # noqa: PYI034
def __new__(cls, value: int, stream: BinaryIO | None, context: dict[str, Any] | None = None) -> Pointer[T]:
obj = super().__new__(cls, value)
obj._stream = stream
obj._context = context
Expand Down Expand Up @@ -70,15 +72,15 @@ def __default__(cls) -> Pointer:
return cls.__new__(cls, cls.cs.pointer.__default__(), None, None)

@classmethod
def _read(cls, stream: BinaryIO, context: dict[str, Any] | None = None) -> Pointer:
def _read(cls, stream: BinaryIO, context: dict[str, Any] | None = None) -> Pointer[T]:
return cls.__new__(cls, cls.cs.pointer._read(stream, context), stream, context)

@classmethod
def _write(cls, stream: BinaryIO, data: int) -> int:
return cls.cs.pointer._write(stream, data)

def dereference(self) -> Any:
if self == 0 or self._stream is None:
def dereference(self) -> T:
if self == 0:
raise NullPointerDereference

if self._value is None and not issubclass(self.type, Void):
Expand All @@ -97,3 +99,7 @@ def dereference(self) -> Any:
self._value = value

return self._value

@classmethod
def _type_stub(cls, name: str = "", underscore: bool = False) -> str:
return f"{name}: {cls.__base__.__name__}[{cls.type.__name__}]"
Loading