From f25239e87823bbd25fc1fad4571cf988c17a4a0f Mon Sep 17 00:00:00 2001 From: AT0myks Date: Wed, 28 Jun 2023 13:34:59 +0200 Subject: [PATCH] Type hints --- pycramfs/__init__.py | 44 ++++++++++------- pycramfs/__main__.py | 28 +++++------ pycramfs/const.py | 21 +++++---- pycramfs/extract.py | 39 ++++++++++----- pycramfs/file.py | 107 +++++++++++++++++++++++++----------------- pycramfs/py.typed | 0 pycramfs/structure.py | 30 ++++++++++-- pycramfs/types.py | 13 +++++ pycramfs/util.py | 33 ++++++++----- pyproject.toml | 4 +- 10 files changed, 208 insertions(+), 111 deletions(-) create mode 100644 pycramfs/py.typed create mode 100644 pycramfs/types.py diff --git a/pycramfs/__init__.py b/pycramfs/__init__.py index 3020dab..6d15167 100644 --- a/pycramfs/__init__.py +++ b/pycramfs/__init__.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import io from functools import partial from pathlib import PurePosixPath +from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Optional from zlib import crc32 from pycramfs.const import CRC_OFFSET, CRC_SIZE @@ -8,12 +11,21 @@ from pycramfs.structure import Super from pycramfs.util import BoundedSubStream, test_super +if TYPE_CHECKING: + from pycramfs.types import ByteStream, FileDescriptorOrPath, ReadableBuffer, StrPath + __version__ = "1.0.0" class Cramfs: - def __init__(self, fd: BoundedSubStream, super: Super, rootdir: Directory = None, closefd: bool = True): + def __init__( + self, + fd: ByteStream, + super: Super, + rootdir: Directory, + closefd: bool = True + ) -> None: self._fd = fd self._super = super self._rootdir = rootdir @@ -22,17 +34,17 @@ def __init__(self, fd: BoundedSubStream, super: Super, rootdir: Directory = None def __enter__(self): return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, *_: Any) -> None: if self._closefd: self.close() - def __len__(self): + def __len__(self) -> int: return self._super.fsid.files - def __iter__(self): + def __iter__(self) -> Iterator[File]: yield from self._rootdir.riter() - def __contains__(self, item): + def __contains__(self, item: Any): if isinstance(item, (str, PurePosixPath)): return self.select(item) is not None elif isinstance(item, File): @@ -58,16 +70,16 @@ def size(self) -> int: def close(self) -> None: self._fd.close() - def find(self, filename): + def find(self, filename: StrPath) -> Optional[File]: return self._rootdir.find(filename) - def select(self, path): + def select(self, path: StrPath) -> Optional[File]: return self._rootdir.select(path) - def itermatch(self, pattern): + def itermatch(self, pattern: str) -> Iterator[File]: yield from self._rootdir.itermatch(pattern) - def calculate_crc(self, size=1024**2): + def calculate_crc(self, size: int = 1024**2) -> int: self._fd.seek(0) crc = crc32(self._fd.read(CRC_OFFSET)) # Read until CRC self._fd.read(CRC_SIZE) # Read the CRC but ignore it @@ -77,7 +89,7 @@ def calculate_crc(self, size=1024**2): return crc @classmethod - def from_fd(cls, fd, offset=0, closefd=True): + def from_fd(cls, fd: BinaryIO, offset: int = 0, closefd: bool = True): """Create a Cramfs object from a file descriptor. `offset` must be the absolute position at which the superblock starts. @@ -85,15 +97,15 @@ def from_fd(cls, fd, offset=0, closefd=True): fd.seek(offset) super = Super.from_fd(fd) test_super(super) - fd = BoundedSubStream(fd, offset, offset + super.size) - self = cls(fd, super, closefd=closefd) - self._rootdir = Directory.from_fd(fd, self, self._super.root) + fd_ = BoundedSubStream(fd, offset, offset + super.size) + self = cls(fd_, super, None, closefd) # type: ignore + self._rootdir = Directory.from_fd(fd_, self, self._super.root) return self @classmethod - def from_bytes(cls, bytes_, offset=0): + def from_bytes(cls, bytes_: ReadableBuffer, offset: int = 0): return cls.from_fd(io.BytesIO(bytes_), offset) @classmethod - def from_file(cls, path, offset=0): - return cls.from_fd(open(path, "rb"), offset) + def from_file(cls, file: FileDescriptorOrPath, offset: int = 0): + return cls.from_fd(open(file, "rb"), offset) diff --git a/pycramfs/__main__.py b/pycramfs/__main__.py index 9238860..3c2c096 100644 --- a/pycramfs/__main__.py +++ b/pycramfs/__main__.py @@ -1,23 +1,23 @@ -import argparse +from argparse import ArgumentParser, Namespace from pathlib import Path, PurePosixPath from pycramfs import Cramfs, __version__ from pycramfs.const import PAGE_SIZE from pycramfs.exception import CramfsError from pycramfs.extract import extract_dir, extract_file -from pycramfs.file import filetype +from pycramfs.file import Directory, File, Symlink, filetype from pycramfs.util import _print, find_superblocks -def print_file(file): +def print_file(file: File) -> None: # Max file size is 2**24-1 or 16777215 -> 8 characters. # Max UID is 2**16-1 or 65535 -> 5 characters. # Max GID is 2**8-1 or 255 -> 3 characters. - link = f"-> {file.readlink()}" if file.is_symlink else '' + link = f"-> {file.readlink()}" if isinstance(file, Symlink) else '' print(file.filemode, f"{file.size:8} {file.uid:5}:{file.gid:<3}", file.path, link) -def list_(args): +def list_(args: Namespace) -> None: if (types := args.type) is not None: types = set(''.join(types).replace('f', '-')) count = 0 @@ -38,15 +38,15 @@ def list_(args): print(f"{count} file(s) found") -def info(args): +def info(args: Namespace) -> None: width = 10 superblocks = find_superblocks(args.file) if not superblocks: print("No superblock found") return for idx, superblock in enumerate(superblocks): - super = argparse.Namespace(**superblock) - fsid = argparse.Namespace(**super.fsid) + super = Namespace(**superblock) + fsid = Namespace(**super.fsid) print(f"Superblock #{idx + 1}") print(f"{'Magic:':{width}} 0x{super.magic:X}") print(f"{'Size:':{width}} {super.size:,}") @@ -63,13 +63,13 @@ def info(args): print() -def extract(args): +def extract(args: Namespace) -> None: dest = args.dest with Cramfs.from_file(args.file, args.offset) as cramfs: file = cramfs.select(args.path) if file is None: raise CramfsError(f"{args.path} not found") - elif file.is_dir: + elif isinstance(file, Directory): if dest is None: dest = args.file.with_name(args.file.stem) amount = extract_dir(file, dest, args.force, args.quiet) @@ -80,7 +80,7 @@ def extract(args): _print(f"{int(amount)} file(s) extracted to {dest.resolve()}", quiet=args.quiet) -def check(args): +def check(args: Namespace) -> None: with Cramfs.from_file(args.file, args.offset) as cramfs: for file in cramfs: if file.inode.namelen == 0 and str(file.path) != '/': @@ -115,13 +115,13 @@ def check(args): def main(): filetypes = list(''.join(filetype).replace('-', 'f')) - pfile = argparse.ArgumentParser(add_help=False) + pfile = ArgumentParser(add_help=False) pfile.add_argument("file", type=Path) - poffset = argparse.ArgumentParser(add_help=False) + poffset = ArgumentParser(add_help=False) poffset.add_argument("-o", "--offset", type=int, default=0, help="absolute position of file system's start. Default: %(default)s") - parser = argparse.ArgumentParser() + parser = ArgumentParser() parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") subparsers = parser.add_subparsers(required=True, dest="command") # dest is only here to avoid a TypeError in Python 3.8 (see bpo-29298) diff --git a/pycramfs/const.py b/pycramfs/const.py index 8455f2a..99ccfb7 100644 --- a/pycramfs/const.py +++ b/pycramfs/const.py @@ -1,7 +1,8 @@ from enum import IntEnum, IntFlag +from typing import Final -MAGIC = 0x28CD3D45 -SIGNATURE = "Compressed ROMFS" +MAGIC: Final = 0x28CD3D45 +SIGNATURE: Final = "Compressed ROMFS" class Width(IntEnum): @@ -13,9 +14,9 @@ class Width(IntEnum): OFFSET = 26 -MAXPATHLEN = ((1 << Width.NAMELEN) - 1) << 2 +MAXPATHLEN: Final = ((1 << Width.NAMELEN) - 1) << 2 -PAGE_SIZE = 4096 +PAGE_SIZE: Final = 4096 class Flag(IntFlag): @@ -41,12 +42,12 @@ class BlockFlag(IntFlag): DIRECT_PTR = 1 << 30 -BLK_FLAGS = BlockFlag.UNCOMPRESSED | BlockFlag.DIRECT_PTR +BLK_FLAGS: Final = BlockFlag.UNCOMPRESSED | BlockFlag.DIRECT_PTR -CRC_OFFSET = 32 # Bytes -CRC_SIZE = 4 # Bytes +CRC_OFFSET: Final = 32 # Bytes +CRC_SIZE: Final = 4 # Bytes -BLK_PTR_FMT = " None: pass @@ -28,9 +43,9 @@ def write_file(path: Path, file: Optional[DataFile] = None, force: bool = False) try: - from os import mknod + from os import mknod # type: ignore except ImportError: - def mknod(path, mode=0o600, device=0): + def mknod(path: Path, mode: int = 0o600, device: int = 0) -> None: write_file(path) @@ -49,15 +64,15 @@ def change_file_status(path: Path, inode: Inode) -> None: utime(path, (0, 0)) -def extract_file(file, dest: Path, force: bool = False, quiet: bool = True) -> bool: +def extract_file(file: File, dest: Path, force: bool = False, quiet: bool = True) -> bool: """Extract a file that is not a directory. Return whether the file was created. """ - if file.is_file: + if isinstance(file, RegularFile): write_file(dest, file, force) chmod(dest, file.mode) - elif file.is_symlink: + elif isinstance(file, Symlink): try: dest.symlink_to(file.read_bytes()) except FileExistsError: @@ -70,9 +85,9 @@ def extract_file(file, dest: Path, force: bool = False, quiet: bool = True) -> b # Either the user is unprivileged or Developer Mode is not enabled. write_file(dest, file, force) else: - if file.is_char_device or file.is_block_device: + if isinstance(file, (CharacterDevice, BlockDevice)): devtype = file.size - elif file.is_fifo or file.is_socket: + elif isinstance(file, (FIFO, Socket)): devtype = 0 else: _print(f"bogus mode: {file.path} ({file.mode:o})", quiet=quiet) @@ -82,7 +97,7 @@ def extract_file(file, dest: Path, force: bool = False, quiet: bool = True) -> b return True -def extract_dir(directory: Directory, dest: Path, force: bool = False, quiet: bool = True): +def extract_dir(directory: Directory, dest: Path, force: bool = False, quiet: bool = True) -> int: """Extract a directory tree. Return the amount of files created.""" total = directory.total width = 2**Width.NAMELEN diff --git a/pycramfs/file.py b/pycramfs/file.py index 4f8f060..34f7112 100644 --- a/pycramfs/file.py +++ b/pycramfs/file.py @@ -1,42 +1,55 @@ +from __future__ import annotations + import fnmatch import struct import zlib from pathlib import PurePosixPath -from typing import Iterator +from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Literal, Optional, Tuple from pycramfs.const import BLK_FLAGS, BLK_PTR_FMT, PAGE_SIZE, BlockFlag from pycramfs.exception import CramfsError from pycramfs.structure import Inode +if TYPE_CHECKING: + from pycramfs import Cramfs + from pycramfs.types import ByteStream, StrPath + class File: """Abstract base class for files.""" - def __init__(self, fd, image, inode: Inode, name: bytes, parent=None): + def __init__( + self, + fd: ByteStream, + image: Cramfs, + inode: Inode, + name: bytes, + parent: Optional[Directory] = None + ) -> None: self._fd = fd self._image = image self._inode = inode self._name = name self._parent = parent - def __str__(self): + def __str__(self) -> str: return self.name - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__qualname__}({self.name!r})" - def __lt__(self, other): + def __lt__(self, other: Any) -> bool: if not isinstance(other, File): return NotImplemented return self._name < other._name - def __le__(self, other): + def __le__(self, other: Any) -> bool: if not isinstance(other, File): return NotImplemented return self._name <= other._name @property - def image(self): + def image(self) -> Cramfs: return self._image @property @@ -48,7 +61,7 @@ def name(self) -> str: return self._name.decode() @property - def parent(self): + def parent(self) -> Optional[Directory]: return self._parent @property @@ -87,89 +100,97 @@ def filemode(self) -> str: return self._inode.filemode @property - def is_dir(self): + def is_dir(self) -> bool: return False @property - def is_file(self): + def is_file(self) -> bool: return False @property - def is_symlink(self): + def is_symlink(self) -> bool: return False @property - def is_block_device(self): + def is_block_device(self) -> bool: return False @property - def is_char_device(self): + def is_char_device(self) -> bool: return False @property - def is_fifo(self): + def is_fifo(self) -> bool: return False @property - def is_socket(self): + def is_socket(self) -> bool: return False class Directory(File): - def __init__(self, fd, image, inode: Inode, name: bytes = b'', parent=None, files=None): + def __init__( + self, + fd: ByteStream, + image: Cramfs, + inode: Inode, + name: bytes = b'', + parent: Optional[Directory] = None, + files: Optional[Dict[str, File]] = None + ) -> None: super().__init__(fd, image, inode, name, parent) self._files = files if files is not None else {} self._total = None - def __len__(self): + def __len__(self) -> int: return len(self._files) - def __iter__(self): + def __iter__(self) -> Iterator[File]: yield from self.iterdir() - def __getitem__(self, key): + def __getitem__(self, key: str) -> File: return self._files[key] - def __contains__(self, item): + def __contains__(self, item: Any) -> bool: if isinstance(item, str): return item in self._files elif isinstance(item, File): return item in self._files.values() return False - def __reversed__(self): + def __reversed__(self) -> Iterator[File]: for filename in reversed(self._files): yield self._files[filename] @property - def is_dir(self): + def is_dir(self) -> Literal[True]: return True @property - def files(self): + def files(self) -> Dict[str, File]: return self._files @property - def total(self): + def total(self) -> int: """Return the total amount of files in this subtree.""" if self._total is None: - self._total = len(self) + sum(child.total for child in self._files.values() if child.is_dir) + self._total = len(self) + sum(child.total for child in self._files.values() if isinstance(child, Directory)) return self._total - def iterdir(self): + def iterdir(self) -> Iterator[File]: yield from self._files.values() - def riter(self): + def riter(self) -> Iterator[File]: """Iterate over this directory recursively.""" yield self for file in self._files.values(): - if file.is_dir: + if isinstance(file, Directory): yield from file.riter() else: yield file - def find(self, filename): + def find(self, filename: StrPath) -> Optional[File]: """Find a file of any kind anywhere under this directory.""" filename = PurePosixPath(filename).name for file in self.riter(): @@ -177,7 +198,7 @@ def find(self, filename): return file return None - def select(self, path): + def select(self, path: StrPath) -> Optional[File]: """Select a file of any kind by path. The path can be absolute or relative. @@ -195,13 +216,13 @@ def select(self, path): return self child, *descendants = path.parts if (file := self._files.get(child, None)) is not None: - if file.is_dir and descendants: + if isinstance(file, Directory) and descendants: return file.select(PurePosixPath(*descendants)) elif not descendants: return file return None - def itermatch(self, pattern): + def itermatch(self, pattern: str) -> Iterator[File]: """Iterate over files in this subtree that (fn)match the pattern.""" # We must use str() here because filter doesn't call normcase on Posix. if str(self.path) == '/': @@ -209,16 +230,16 @@ def itermatch(self, pattern): else: paths = (str(file.path.relative_to(self.path)) for file in self.riter()) for path in fnmatch.filter(paths, pattern): - yield self.select(path) + yield self.select(path) # type: ignore @classmethod - def from_fd(cls, fd, image, inode: Inode, name: bytes = b''): + def from_fd(cls, fd: ByteStream, image: Cramfs, inode: Inode, name: bytes = b'') -> Directory: self = cls(fd, image, inode, name) if inode.offset == 0: # Empty dir return self fd.seek(inode.offset) end = inode.size + inode.offset - children = [] + children: List[Tuple[Inode, bytes]] = [] while fd.tell() != end: ino = Inode.from_fd(fd) name = fd.read(ino.namelen).rstrip(b'\x00') @@ -256,52 +277,52 @@ def iter_bytes(self) -> Iterator[bytes]: def read_bytes(self) -> bytes: return b''.join(self.iter_bytes()) - def read_text(self, encoding="utf8", errors="strict") -> str: + def read_text(self, encoding: str = "utf8", errors: str = "strict") -> str: return self.read_bytes().decode(encoding, errors) class RegularFile(DataFile): @property - def is_file(self): + def is_file(self) -> Literal[True]: return True class Symlink(DataFile): @property - def is_symlink(self): + def is_symlink(self) -> Literal[True]: return True - def readlink(self): + def readlink(self) -> PurePosixPath: return PurePosixPath(self.read_text()) class FIFO(File): @property - def is_fifo(self): + def is_fifo(self) -> Literal[True]: return True class Socket(File): @property - def is_socket(self): + def is_socket(self) -> Literal[True]: return True class CharacterDevice(File): @property - def is_char_device(self): + def is_char_device(self) -> Literal[True]: return True class BlockDevice(File): @property - def is_block_device(self): + def is_block_device(self) -> Literal[True]: return True diff --git a/pycramfs/py.typed b/pycramfs/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pycramfs/structure.py b/pycramfs/structure.py index 22dd2bc..6720f94 100644 --- a/pycramfs/structure.py +++ b/pycramfs/structure.py @@ -1,12 +1,18 @@ +from __future__ import annotations + import stat from ctypes import LittleEndianStructure, c_char, c_uint32, sizeof +from typing import TYPE_CHECKING, Iterator, Tuple from pycramfs.const import Flag, Width +if TYPE_CHECKING: + from pycramfs.types import ByteStream, ReadableBuffer, StructAsDict + class _Base(LittleEndianStructure): - def __iter__(self): + def __iter__(self) -> Iterator[Tuple[str, StructAsDict]]: # This allows calling dict() on instances of this class. for name, *_ in self._fields_: name = name.lstrip('_') @@ -14,11 +20,11 @@ def __iter__(self): yield name, dict(attr) if isinstance(attr, _Base) else attr @classmethod - def from_bytes(cls, bytes_): + def from_bytes(cls, bytes_: ReadableBuffer): return cls.from_buffer_copy(bytes_) @classmethod - def from_fd(cls, fd): + def from_fd(cls, fd: ByteStream): return cls.from_bytes(fd.read(sizeof(cls))) @@ -31,6 +37,12 @@ class Inode(_Base): ("_namelen", c_uint32, Width.NAMELEN), ("_offset", c_uint32, Width.OFFSET), ] + _mode: int + _uid: int + _size: int + _gid: int + _namelen: int + _offset: int @property def mode(self) -> int: @@ -98,6 +110,10 @@ class Info(_Base): ("_blocks", c_uint32), ("_files", c_uint32), ] + _crc: int + _edition: int + _blocks: int + _files: int @property def crc(self) -> int: @@ -127,6 +143,14 @@ class Super(_Base): ("_name", c_char * 16), ("_root", Inode), ] + _magic: int + _size: int + _flags: int + _future: int + _signature: bytes + _fsid: Info + _name: bytes + _root: Inode @property def magic(self) -> int: diff --git a/pycramfs/types.py b/pycramfs/types.py new file mode 100644 index 0000000..5107a7e --- /dev/null +++ b/pycramfs/types.py @@ -0,0 +1,13 @@ +from os import PathLike +from typing import BinaryIO, Dict, Union + +from pycramfs.const import Flag +from pycramfs.util import BoundedSubStream + + +ByteStream = Union[BinaryIO, BoundedSubStream[bytes]] +ReadableBuffer = Union[bytes, bytearray, memoryview] +StrOrBytesPath = Union[str, bytes, PathLike[str], PathLike[bytes]] +FileDescriptorOrPath = Union[int, StrOrBytesPath] +StrPath = Union[str, PathLike[str]] +StructAsDict = Dict[str, Union[int, str, Flag, "StructAsDict"]] diff --git a/pycramfs/util.py b/pycramfs/util.py index 72657b8..9f3153c 100644 --- a/pycramfs/util.py +++ b/pycramfs/util.py @@ -1,19 +1,25 @@ +from __future__ import annotations + import io from functools import partial from pathlib import Path +from typing import TYPE_CHECKING, IO, Any, AnyStr, List, Optional, Set, Union from pycramfs.const import MAGIC, MAGIC_BYTES, SIGNATURE, SIGNATURE_BYTES, SUPPORTED_FLAGS, Flag from pycramfs.exception import CramfsError from pycramfs.file import PAGE_SIZE from pycramfs.structure import Super +if TYPE_CHECKING: + from pycramfs.types import FileDescriptorOrPath, ReadableBuffer, StructAsDict + -class BoundedSubStream: +class BoundedSubStream(IO[AnyStr]): """Wrapper around a stream with boundaries that won't allow to move before the start or past the end of a sub stream. """ - def __init__(self, fd, start=0, end=None) -> None: + def __init__(self, fd: IO[AnyStr], start: int = 0, end: Optional[int] = None) -> None: """`start` and `end` are the absolute limits of the sub stream. `end` will usually be `start` + data size. @@ -22,7 +28,7 @@ def __init__(self, fd, start=0, end=None) -> None: self._start = start self._end = end if end is not None else self._find_size() - def __getattr__(self, name: str): + def __getattr__(self, name: str) -> Any: # Treat anything else as if called directly on the wrapped stream. return getattr(self._fd, name) @@ -32,13 +38,13 @@ def _find_size(self) -> int: self._fd.seek(pos) return size - def read(self, size=-1, /) -> bytes: + def read(self, size: Optional[int] = -1, /) -> AnyStr: max_read = self._end - self._fd.tell() if size is None or size < 0 or size > max_read: size = max_read return self._fd.read(size) - def seek(self, offset, whence=io.SEEK_SET, /) -> int: + def seek(self, offset: int, whence: int = io.SEEK_SET, /) -> int: if whence == io.SEEK_SET: offset = max(self._start, offset + self._start) elif whence == io.SEEK_CUR: @@ -56,13 +62,16 @@ def tell(self) -> int: return self._fd.tell() - self._start -def find_superblocks(file_or_bytes, size=1024**2): +def find_superblocks( + file_or_bytes: Union[FileDescriptorOrPath, ReadableBuffer], + size: int = 1024**2 +) -> List[StructAsDict]: """Return a list of dictionaries representing the superblocks found in the file with their offset. """ count = 0 - indexes = set() - result = [] + indexes: Set[int] = set() + result: List[StructAsDict] = [] if isinstance(file_or_bytes, (str, Path)): stream = open(file_or_bytes, "rb") elif isinstance(file_or_bytes, (bytes, bytearray)): @@ -84,12 +93,12 @@ def find_superblocks(file_or_bytes, size=1024**2): super = Super.from_fd(f) # It's possible that the magic shows up but is just random bytes. # That's why we dont decode() the signature and compare the raw bytes. - if super.magic == MAGIC and super._signature == SIGNATURE_BYTES: + if super.magic == MAGIC and super._signature == SIGNATURE_BYTES: # type: ignore result.append(dict(super, offset=index)) return result -def test_super(superblock: Super): +def test_super(superblock: Super) -> None: if superblock.magic != MAGIC: raise CramfsError("wrong magic") if superblock.signature != SIGNATURE: @@ -105,6 +114,6 @@ def test_super(superblock: Super): print("WARNING: old cramfs format") -def _print(*args, **kwargs): - if not kwargs.pop("quiet", False): +def _print(*args: object, quiet: bool = False, **kwargs: Any) -> None: + if not quiet: print(*args, **kwargs) diff --git a/pyproject.toml b/pyproject.toml index 9d65c5a..ee4685a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,8 @@ classifiers = [ "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Filesystems", - "Topic :: Utilities" + "Topic :: Utilities", + "Typing :: Typed" ] dynamic = ["version"] @@ -40,6 +41,7 @@ pycramfs = "pycramfs.__main__:main" [tool.setuptools] packages = ["pycramfs"] +package-data = {pycramfs = ["py.typed"]} [tool.setuptools.dynamic] version = {attr = "pycramfs.__version__"} \ No newline at end of file