diff --git a/MANIFEST.in b/MANIFEST.in index 4564c73d3..464ddb004 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -10,6 +10,11 @@ prune .venv prune docs prune examples/captures prune temp +# The engine stubs mypy reads (#1878) check this tree and are not for shipping. +# Not load-bearing today: ``global-include *.py`` does not match a ``.pyi``, +# measured by building an sdist without this line. It keeps them out should a +# pattern above come to match one. +prune typings # Belt-and-braces, both of them: setuptools ships these two whether or not this # file mentions them, so neither line is load-bearing. An earlier version of this diff --git a/docs/source/contributing/conventions/process.rst b/docs/source/contributing/conventions/process.rst index 4a8f65521..b8869926b 100644 --- a/docs/source/contributing/conventions/process.rst +++ b/docs/source/contributing/conventions/process.rst @@ -57,12 +57,25 @@ back to the default engine rather than raising when a requested engine is absent The ``dev`` extra exists **because** of this narrowing, and is not a second catch-all. pylint, mypy and autodoc all resolve imports against what is installed, - so the engines leaving ``all`` would have made them newly unresolvable to the + mypy for every package without a stub under :file:`typings/` (below), so the + engines leaving ``all`` would have made them newly unresolvable to the toolchain. ``dev`` is defined by what the toolchain must be able to *see*, and the workflows that need full resolution install ``.[all,dev]``. Do not add ``pypcap``/``pcap-ct`` to it to silence a lint finding; :file:`.github/workflows/lint.yml` carries a tracked ``import-error`` count that is deliberate rather than accidental. +.. note:: + + mypy types the engine packages that ship no types -- ``dpkt``, ``pyshark``, + ``pcap`` and ``pcapfile`` -- from local stubs under :file:`typings/`, which + :file:`mypy.ini` registers through ``mypy_path`` and which ship in neither the + wheel nor the sdist (:issue:`1878`). ``scapy`` ships its own. A stub declares only + what :mod:`pcapkit` uses and cites the version it was written against, so a name + :mod:`pcapkit` starts to use goes into the stub first. Each engine's ``_expkg`` is + a :class:`~typing.Protocol` of what the engine reads through it, defined in its + module's ``if TYPE_CHECKING:`` block; :file:`tests/project/test_engine_stubs_1878_unit.py` + checks both against the code. + Changelog Entry Granularity ~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/mypy.ini b/mypy.ini index 7b78086df..4af5cea49 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,4 +1,7 @@ [mypy] +# Local stubs for the untyped engine packages pcapkit uses (#1878). Relative to +# this file rather than to the working directory, which a bare path would be. +mypy_path = $MYPY_CONFIG_FILE_DIR/typings ignore_missing_imports = True disallow_untyped_defs = True disallow_incomplete_defs = True diff --git a/pcapkit/foundation/engines/dpkt.py b/pcapkit/foundation/engines/dpkt.py index c94c53f8f..2197de868 100644 --- a/pcapkit/foundation/engines/dpkt.py +++ b/pcapkit/foundation/engines/dpkt.py @@ -29,14 +29,18 @@ __all__ = ['DPKT', 'PCAPNGReader'] if TYPE_CHECKING: - from types import ModuleType - from typing import IO, Iterator, Literal, Optional, Type, Union + from typing import IO, Iterator, Literal, Optional, Protocol, Type, Union from dpkt.dpkt import Packet as DPKTPacket + from dpkt.ethernet import Ethernet as DPKTEthernet + from dpkt.ip import IP as DPKTIP + from dpkt.ip6 import IP6 as DPKTIP6 + from dpkt.loopback import Loopback as DPKTLoopback from dpkt.pcap import Reader as PCAPReader from pcapkit.foundation.engines.pcapng import Context from pcapkit.foundation.extraction import Extractor + from pcapkit.protocols.data.misc.pcapng import IF_TSOffsetOption as Data_IF_TSOffsetOption from pcapkit.protocols.data.misc.pcapng import IF_TSResolOption as Data_IF_TSResolOption from pcapkit.protocols.data.misc.pcapng import \ InterfaceDescriptionBlock as Data_InterfaceDescriptionBlock @@ -45,6 +49,57 @@ Reader = Union[PCAPReader, 'PCAPNGReader'] + class _DPKTCoreModule(Protocol): + """:mod:`dpkt.dpkt`, as :class:`DPKT` reads it.""" + + @property + def Packet(self) -> 'Type[DPKTPacket]': ... + + class _DPKTEthernetModule(Protocol): + """:mod:`dpkt.ethernet`, as :class:`DPKT` reads it.""" + + @property + def Ethernet(self) -> 'Type[DPKTEthernet]': ... + + class _DPKTIPModule(Protocol): + """:mod:`dpkt.ip`, as :class:`DPKT` reads it.""" + + @property + def IP(self) -> 'Type[DPKTIP]': ... + + class _DPKTIP6Module(Protocol): + """:mod:`dpkt.ip6`, as :class:`DPKT` reads it.""" + + @property + def IP6(self) -> 'Type[DPKTIP6]': ... + + class _DPKTLoopbackModule(Protocol): + """:mod:`dpkt.loopback`, as :class:`DPKT` reads it.""" + + @property + def Loopback(self) -> 'Type[DPKTLoopback]': ... + + class _DPKTPackage(Protocol): + """:mod:`dpkt`, as :class:`DPKT` reads it through :attr:`DPKT._expkg` (#1878). + + Each attribute is a submodule, described by what the engine reads of it. + mypy matches a module with a protocol at the top level only, so the + submodules are checked through the stub types they name, under + :file:`typings/dpkt`. + + """ + + @property + def dpkt(self) -> '_DPKTCoreModule': ... + @property + def ethernet(self) -> '_DPKTEthernetModule': ... + @property + def ip(self) -> '_DPKTIPModule': ... + @property + def ip6(self) -> '_DPKTIP6Module': ... + @property + def loopback(self) -> '_DPKTLoopbackModule': ... + #: logging.Logger: Module-level logger, a child of the package-wide #: :data:`pcapkit.utilities.logging.logger`. logger = get_logger(__name__) @@ -182,8 +237,10 @@ def _read_packet(self, buf: 'bytes', block_type: 'int', raise FormatError(f'PCAP-NG: [{tag}] invalid interface ID: {iface_id}') interface = self._ctx.interfaces[iface_id] - tsresol = interface.options.get(Enum_OptionType.if_tsresol) - tsoffset = interface.options.get(Enum_OptionType.if_tsoffset) + tsresol = cast('Optional[Data_IF_TSResolOption]', + interface.options.get(Enum_OptionType.if_tsresol)) + tsoffset = cast('Optional[Data_IF_TSOffsetOption]', + interface.options.get(Enum_OptionType.if_tsoffset)) resolution = DEFAULT_RESOLUTION if tsresol is None else tsresol.resolution offset = 0 if tsoffset is None else tsoffset.offset @@ -268,7 +325,7 @@ def _pcap_reader() -> 'Type[PCAPReader]': #: Attributes of :class:`dpkt.pcap.Reader` the loop below reads, as of dpkt 1.9.8. private = ('_Reader__f', '_Reader__ph', '_divisor') - class _Reader(dpkt.pcap.Reader): # type: ignore[name-defined,misc] + class _Reader(dpkt.pcap.Reader): def __init__(self, fileobj: 'IO[bytes]') -> 'None': super().__init__(fileobj) missing = [name for name in private if not hasattr(self, name)] @@ -284,8 +341,9 @@ def __init__(self, fileobj: 'IO[bytes]') -> 'None': # NOTE: the loop is :meth:`dpkt.pcap.Reader.__iter__`'s, over the stream # and the record header type that method reads them through. A generator, # so ``dpkt.pcap.Reader.__init__`` calling it runs none of it before - # :attr:`_upstream` is set. - def __iter__(self) -> 'Iterator[tuple[float, bytes]]': + # :attr:`_upstream` is set. The timestamp is a ``Decimal`` for a + # nanosecond file, from upstream's loop and from this one alike. + def __iter__(self) -> 'Iterator[tuple[float | decimal.Decimal, bytes]]': if self._upstream: yield from super().__iter__() return @@ -347,7 +405,10 @@ def _loopback_protocol(base: 'Type[DPKTPacket]', ip: 'Type[DPKTPacket]', ip6: 'T layers = {2: ip, 24: ip6, 28: ip6, 30: ip6} - class Loopback(base): + # ``base`` is an argument so that the class derives from the given module's + # own ``Loopback``; mypy takes a base class only from a name it can resolve + # statically + class Loopback(base): # type: ignore[valid-type,misc] """BSD loopback encapsulation.""" def unpack(ext, buf: 'bytes') -> 'None': @@ -382,7 +443,7 @@ class DPKT(EngineBase['DPKTPacket']): """ if TYPE_CHECKING: #: Engine extraction package, i.e. :mod:`dpkt`. - _expkg: 'ModuleType' + _expkg: '_DPKTPackage' #: Engine extraction temporary storage. _extmp: 'Reader' @@ -464,7 +525,7 @@ def run(self) -> 'None': if ext.magic_number in PCAP.MAGIC_NUMBER: logger.debug('dpkt: reading %s as PCAP', ext._ifnm) - reader = _pcap_reader()(ext._ifile) + reader = _pcap_reader()(ext._ifile) # type: Reader elif ext.magic_number in PCAPNG.MAGIC_NUMBER: logger.debug('dpkt: reading %s as PCAP-NG', ext._ifnm) reader = PCAPNGReader(ext._ifile) @@ -594,6 +655,7 @@ def _get_protocol(self, linktype: 'Optional[Enum_LinkType]' = None, linktype = Enum_LinkType.get(reader.datalink()) version = data[0] >> 4 if data else None + pkg: 'Type[DPKTPacket]' if linktype == Enum_LinkType.ETHERNET: pkg = dpkt.ethernet.Ethernet elif linktype.value == Enum_LinkType.IPV4 or (linktype.value == Enum_LinkType.RAW and version == 4): diff --git a/pcapkit/foundation/engines/pcap_ct.py b/pcapkit/foundation/engines/pcap_ct.py index 2ca3ca7bc..cc78221b3 100644 --- a/pcapkit/foundation/engines/pcap_ct.py +++ b/pcapkit/foundation/engines/pcap_ct.py @@ -24,13 +24,18 @@ __all__ = ['PCAP_CT'] if TYPE_CHECKING: - from types import ModuleType - from typing import Iterator, Optional + from typing import Iterator, Optional, Protocol, Type from pcap import pcap as Handle from pcapkit.foundation.extraction import Extractor + class _PCAPCTPackage(Protocol): + """:mod:`pcap`, as :class:`PCAP_CT` reads it through :attr:`PCAP_CT._expkg` (#1878).""" + + @property + def pcap(self) -> 'Type[Handle]': ... + #: A `pcap-ct`_ "frame": the ``(timestamp, bytes)`` pair that #: :class:`pcap.pcap` iteration yields. Deliberately *not* named ``Frame``, #: so that it is not mistaken for @@ -113,7 +118,7 @@ class PCAP_CT(EngineBase['RawFrame']): """ if TYPE_CHECKING: #: Engine extraction package, i.e. :mod:`pcap`. - _expkg: 'ModuleType' + _expkg: '_PCAPCTPackage' #: Capture handle, kept separately from the iterator it is read through #: so that :meth:`close` does not depend on ``iter(handle) is handle``. _handle: 'Handle' @@ -395,7 +400,7 @@ def run(self) -> 'None': # want that attempt to request promiscuous mode. (That fallback is also # why a bad path surfaces as ``OSError: ... No such device exists``, # which is what the ``os.path.isfile`` check above pre-empts.) - self._handle = cast('Handle', self._expkg.pcap(name=ext._ifnm, promisc=False)) + self._handle = self._expkg.pcap(name=ext._ifnm, promisc=False) self._dlink = Enum_LinkType.get(self._handle.datalink()) # setup verbose handler diff --git a/pcapkit/foundation/engines/pypcap.py b/pcapkit/foundation/engines/pypcap.py index fb3b3b2e3..d786e1668 100644 --- a/pcapkit/foundation/engines/pypcap.py +++ b/pcapkit/foundation/engines/pypcap.py @@ -31,13 +31,18 @@ __all__ = ['PyPCAP'] if TYPE_CHECKING: - from types import ModuleType - from typing import Iterator, Optional + from typing import Iterator, Optional, Protocol, Type from pcap import pcap as Handle from pcapkit.foundation.extraction import Extractor + class _PyPCAPPackage(Protocol): + """:mod:`pcap`, as :class:`PyPCAP` reads it through :attr:`PyPCAP._expkg` (#1878).""" + + @property + def pcap(self) -> 'Type[Handle]': ... + #: A PyPCAP "frame": the ``(timestamp, bytes)`` pair that :class:`pcap.pcap` #: iteration yields. Deliberately *not* named ``Frame``, so that it is not #: mistaken for :class:`pcapkit.protocols.misc.pcap.frame.Frame`, which is @@ -88,7 +93,7 @@ class PyPCAP(EngineBase['RawFrame']): """ if TYPE_CHECKING: #: Engine extraction package, i.e. :mod:`pcap`. - _expkg: 'ModuleType' + _expkg: '_PyPCAPPackage' #: Engine extraction temporary storage. _extmp: 'Iterator[RawFrame]' #: Data link layer protocol, from the capture handle. @@ -308,7 +313,7 @@ def run(self) -> 'None': # ``pcap.pcap`` ignores it, but should ``pcap_open_offline`` ever fail the # constructor falls through to opening the name as a live device, and we # do not want that attempt to request promiscuous mode. - handle = cast('Handle', self._expkg.pcap(name=ext._ifnm, promisc=False)) + handle = self._expkg.pcap(name=ext._ifnm, promisc=False) self._dlink = Enum_LinkType.get(handle.datalink()) # setup verbose handler diff --git a/pcapkit/foundation/engines/pypcapfile.py b/pcapkit/foundation/engines/pypcapfile.py index 339b06b8a..721bf8ef7 100644 --- a/pcapkit/foundation/engines/pypcapfile.py +++ b/pcapkit/foundation/engines/pypcapfile.py @@ -27,14 +27,49 @@ __all__ = ['PyPCAPFile'] if TYPE_CHECKING: - from types import ModuleType - from typing import Any, Callable, Iterator, Optional + from typing import Any, Callable, Iterator, Optional, Protocol, Type + from pcapfile.protocols.linklayer.ethernet import Ethernet as PCAPFileEthernet + from pcapfile.savefile import pcap_savefile as PCAPFileSavefile from pcapfile.structs import __pcap_header__ as PCAPFileHeader from pcapfile.structs import pcap_packet as PCAPFilePacket from pcapkit.foundation.extraction import Extractor + class _PCAPFileLinkLayerModule(Protocol): + """:mod:`pcapfile.linklayer`, as :class:`PyPCAPFile` reads it.""" + + def clookup(self, ll_type: 'int') -> 'Optional[Type[PCAPFileEthernet]]': ... + + class _PCAPFileSavefileModule(Protocol): + """:mod:`pcapfile.savefile`, as :class:`PyPCAPFile` reads it.""" + + def load_savefile(self, input_file: 'NamedStream', layers: 'int' = ..., + verbose: 'bool' = ..., lazy: 'bool' = ...) -> 'PCAPFileSavefile': ... + + class _PCAPFileStructsModule(Protocol): + """:mod:`pcapfile.structs`, as :class:`PyPCAPFile` reads it.""" + + @property + def pcap_packet(self) -> 'Type[PCAPFilePacket]': ... + + class _PCAPFilePackage(Protocol): + """:mod:`pcapfile`, as :class:`PyPCAPFile` reads it through :attr:`PyPCAPFile._expkg` (#1878). + + Each attribute is a submodule, described by what the engine reads of it. + mypy matches a module with a protocol at the top level only, so the + submodules are checked through the stub types they name, under + :file:`typings/pcapfile`. + + """ + + @property + def linklayer(self) -> '_PCAPFileLinkLayerModule': ... + @property + def savefile(self) -> '_PCAPFileSavefileModule': ... + @property + def structs(self) -> '_PCAPFileStructsModule': ... + class PyPCAPFile(EngineBase['PCAPFilePacket']): """PyPCAPFile engine support. @@ -70,7 +105,7 @@ class PyPCAPFile(EngineBase['PCAPFilePacket']): """ if TYPE_CHECKING: #: Engine extraction package, i.e. :mod:`pcapfile`. - _expkg: 'ModuleType' + _expkg: '_PCAPFilePackage' #: Engine extraction temporary storage. _extmp: 'Iterator[PCAPFilePacket]' #: Data link layer protocol, from the savefile header. @@ -372,9 +407,9 @@ def _read_records(self, file: 'NamedStream', warn(f'PCAP: [Frame {number}] captured length {capture_len} runs past the data, ' f'which holds {len(data)} octet(s); kept as captured', ProtocolWarning, stacklevel=stacklevel()) - yield cast('PCAPFilePacket', self._expkg.structs.pcap_packet( + yield self._expkg.structs.pcap_packet( hdrp, timestamp, timestamp_us, capture_len, packet_len, binascii.hexlify(data), - )) + ) def _decode(self, packet: 'PCAPFilePacket', frnum: 'int') -> 'PCAPFilePacket': """Decode a raw savefile packet down to :attr:`LAYERS` depth. @@ -417,7 +452,7 @@ def _decode(self, packet: 'PCAPFilePacket', frnum: 'int') -> 'PCAPFilePacket': 'frame left undecoded', AttributeWarning, stacklevel=stacklevel()) return packet - return cast('PCAPFilePacket', self._expkg.structs.pcap_packet( + return self._expkg.structs.pcap_packet( packet.header, packet.timestamp, packet.timestamp_us, packet.capture_len, packet.packet_len, decoded, - )) + ) diff --git a/pcapkit/foundation/engines/pyshark.py b/pcapkit/foundation/engines/pyshark.py index 4bf83e1ef..2c0681f2f 100644 --- a/pcapkit/foundation/engines/pyshark.py +++ b/pcapkit/foundation/engines/pyshark.py @@ -22,15 +22,20 @@ __all__ = ['PyShark'] if TYPE_CHECKING: - from types import ModuleType - from typing import Optional + from typing import Optional, Protocol, Type - from pyshark.capture.file_capture import FileCapture + from pyshark.capture.file_capture import FileCapture as PySharkFileCapture from pyshark.packet.layers.base import BaseLayer from pyshark.packet.packet import Packet as PySharkPacket from pcapkit.foundation.extraction import Extractor + class _PySharkPackage(Protocol): + """:mod:`pyshark`, as :class:`PyShark` reads it through :attr:`PyShark._expkg` (#1878).""" + + @property + def FileCapture(self) -> 'Type[PySharkFileCapture]': ... + #: logging.Logger: Module-level logger, a child of the package-wide #: :data:`pcapkit.utilities.logging.logger`. logger = get_logger(__name__) @@ -84,9 +89,9 @@ class PyShark(EngineBase['PySharkPacket']): """ if TYPE_CHECKING: #: Engine extraction package, i.e. :mod:`pyshark`. - _expkg: 'ModuleType' + _expkg: '_PySharkPackage' #: Engine extraction temporary storage. - _extmp: 'FileCapture' + _extmp: 'PySharkFileCapture' ########################################################################## # Defaults. @@ -200,7 +205,7 @@ def __init__(self, extractor: 'Extractor') -> 'None': import pyshark # isort:skip self._expkg = pyshark - self._extmp = cast('FileCapture', None) + self._extmp = cast('PySharkFileCapture', None) super().__init__(extractor) @@ -288,7 +293,7 @@ def read_frame(self) -> 'PySharkPacket': # fetch the next PyShark packet that is a packet record while True: - packet = cast('PySharkPacket', self._extmp.next()) + packet = self._extmp.next() if _is_packet(packet): break logger.debug('pyshark: skipping tshark frame %s, not a packet record', packet.number) diff --git a/pcapkit/foundation/engines/scapy.py b/pcapkit/foundation/engines/scapy.py index d46183331..29b6018bf 100644 --- a/pcapkit/foundation/engines/scapy.py +++ b/pcapkit/foundation/engines/scapy.py @@ -51,14 +51,23 @@ __all__ = ['Scapy'] if TYPE_CHECKING: - from types import ModuleType - from typing import Any, Iterator, Type + from typing import Any, Iterator, Protocol, Type from scapy.packet import Packet as ScapyPacket + from scapy.plist import PacketList from scapy.utils import PcapReader from pcapkit.foundation.extraction import Extractor + class _ScapyPackage(Protocol): + """:mod:`scapy.all`, as :class:`Scapy` reads it through :attr:`Scapy._expkg` (#1878). + + Typed by scapy itself, which ships its own type information. + + """ + + def sniff(self, *args: 'Any', **kwargs: 'Any') -> 'PacketList': ... + #: logging.Logger: Module-level logger, a child of the package-wide #: :data:`pcapkit.utilities.logging.logger`. logger = get_logger(__name__) @@ -284,7 +293,7 @@ class Scapy(EngineBase['ScapyPacket']): """ if TYPE_CHECKING: #: Engine extraction package, i.e. :mod:`scapy.all`. - _expkg: 'ModuleType' + _expkg: '_ScapyPackage' #: Engine extraction temporary storage. _extmp: 'Iterator[ScapyPacket]' diff --git a/pcapkit/toolkit/dpkt.py b/pcapkit/toolkit/dpkt.py index ab109b4c6..2340e76dc 100644 --- a/pcapkit/toolkit/dpkt.py +++ b/pcapkit/toolkit/dpkt.py @@ -280,7 +280,7 @@ def packet2bytes(packet: 'Packet') -> 'bytes': buffer = getattr(packet, BUFFER_ATTR, None) if buffer is not None: return cast('bytes', buffer) - return cast('bytes', copy.deepcopy(packet).pack()) + return copy.deepcopy(packet).pack() def packet2frame(packet: 'Packet', timestamp: 'float | Decimal', *, @@ -697,7 +697,9 @@ def ipv6_reassembly(packet: 'Packet', timestamp: 'float', *, network = _network(packet) ipv6 = cast('IP6', network) if hasattr(network, 'extension_hdrs') else None if ipv6 is not None: - ipv6_frag = ipv6.extension_hdrs.get(44) # type: Optional[IP6FragmentHeader] + # dpkt keeps each extension header under its protocol number, so 44 is a + # Fragment header; the dict's value type cannot say so + ipv6_frag = cast('Optional[IP6FragmentHeader]', ipv6.extension_hdrs.get(44)) if ipv6_frag is None: # dismiss not fragmented packet return None hdr_len = ipv6_hdr_len(ipv6) diff --git a/tests/project/test_engine_stubs_1878_unit.py b/tests/project/test_engine_stubs_1878_unit.py new file mode 100644 index 000000000..3d2dfa95a --- /dev/null +++ b/tests/project/test_engine_stubs_1878_unit.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- +"""Each third-party engine types the package it drives (#1878). + +An engine keeps that package in ``_expkg``. #1870 annotated it ``'ModuleType'``, +which mypy reads as :data:`~typing.Any` for every attribute, so nothing called +through it was checked. Each engine now annotates it with a +:class:`~typing.Protocol` of its own, defined in its module-level +``if TYPE_CHECKING:`` block and declaring what the engine reads through it. + +``scapy`` ships its own types. The other engine packages ship none, so +:file:`typings/` holds stubs for the part of each that :mod:`pcapkit` uses, +registered in :file:`mypy.ini` through ``mypy_path``. + +mypy itself is advisory in CI, so these are the parts pinned here: the +annotations, the protocols covering what each engine reads, and the stubs +covering what :mod:`pcapkit` imports. + +This module is unit-tier: it parses source and imports no engine package. + +""" +from __future__ import annotations + +import ast +import configparser +import pathlib +import re +import unittest +from typing import TYPE_CHECKING + +from tests._tiers import ROOT + +if TYPE_CHECKING: + from typing import Iterator, Optional + +#: Engine module, relative to the repository root -> the engine class in it. +ENGINES = { + 'pcapkit/foundation/engines/dpkt.py': 'DPKT', + 'pcapkit/foundation/engines/scapy.py': 'Scapy', + 'pcapkit/foundation/engines/pyshark.py': 'PyShark', + 'pcapkit/foundation/engines/pypcap.py': 'PyPCAP', + 'pcapkit/foundation/engines/pcap_ct.py': 'PCAP_CT', + 'pcapkit/foundation/engines/pypcapfile.py': 'PyPCAPFile', +} + +#: Engine packages that ship no type information, so are stubbed locally. +UNTYPED = ('dpkt', 'pyshark', 'pcap', 'pcapfile') + +#: Directory holding the stubs. +TYPINGS = ROOT / 'typings' + +#: The mypy configuration that registers :data:`TYPINGS`. +MYPY_INI = ROOT / 'mypy.ini' + + +def _parse(path: 'pathlib.Path') -> 'ast.Module': + return ast.parse(path.read_text(encoding='utf-8'), filename=str(path)) + + +def _is_type_checking(test: 'ast.expr') -> bool: + if isinstance(test, ast.Name): + return test.id == 'TYPE_CHECKING' + return isinstance(test, ast.Attribute) and test.attr == 'TYPE_CHECKING' + + +def _guarded(body: 'list[ast.stmt]') -> 'Iterator[ast.stmt]': + """The statements of every ``if TYPE_CHECKING:`` directly in ``body``.""" + for statement in body: + if isinstance(statement, ast.If) and _is_type_checking(statement.test): + yield from statement.body + + +def _class(tree: 'ast.Module', name: str) -> 'ast.ClassDef': + for statement in tree.body: + if isinstance(statement, ast.ClassDef) and statement.name == name: + return statement + raise AssertionError(f'no top-level class {name}') + + +def _expkg_annotation(cls: 'ast.ClassDef') -> 'Optional[str]': + """The annotation of ``_expkg`` in the class body, a quoted one unquoted.""" + for statement in [*cls.body, *_guarded(cls.body)]: + if (isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name) + and statement.target.id == '_expkg'): + annotation = statement.annotation + if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str): + return annotation.value + return ast.unparse(annotation) + return None + + +def _guarded_protocols(tree: 'ast.Module') -> 'dict[str, ast.ClassDef]': + """Every class the module-level guard defines as a ``Protocol``, by name.""" + return { + statement.name: statement for statement in _guarded(tree.body) + if isinstance(statement, ast.ClassDef) + and any(ast.unparse(base) in ('Protocol', 'typing.Protocol') for base in statement.bases) + } + + +def _members(cls: 'ast.ClassDef') -> 'dict[str, str]': + """A protocol's members, each mapped to the annotation of its value.""" + members = {} + for statement in cls.body: + if isinstance(statement, ast.FunctionDef): + members[statement.name] = ast.unparse(statement.returns) if statement.returns else '' + elif isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name): + members[statement.target.id] = ast.unparse(statement.annotation) + return {name: value.strip('\'"') for name, value in members.items()} + + +def _is_expkg(node: 'ast.AST') -> bool: + return (isinstance(node, ast.Attribute) and node.attr == '_expkg' + and isinstance(node.value, ast.Name) and node.value.id == 'self') + + +def _read_paths(cls: 'ast.ClassDef') -> 'set[tuple[str, ...]]': + """Attribute paths each method reads through ``self._expkg``, or a local bound to it. + + ``self._expkg.ip.IP`` and ``dpkt = self._expkg; dpkt.ip.IP`` both give + ``('ip', 'IP')``. + + """ + paths = set() + for method in cls.body: + if not isinstance(method, ast.FunctionDef): + continue + aliases = {target.id for node in ast.walk(method) if isinstance(node, ast.Assign) and _is_expkg(node.value) + for target in node.targets if isinstance(target, ast.Name)} + for node in ast.walk(method): + if not isinstance(node, ast.Attribute): + continue + path = [] # type: list[str] + current = node # type: ast.expr + while isinstance(current, ast.Attribute) and not _is_expkg(current): + path.append(current.attr) + current = current.value + if _is_expkg(current) or (isinstance(current, ast.Name) and current.id in aliases): + paths.add(tuple(reversed(path))) + # keep the longest paths only: ``('ip',)`` is a prefix of ``('ip', 'IP')`` + return {path for path in paths if path and not any( + other[:len(path)] == path and other != path for other in paths)} + + +def _stub_for(module: str) -> 'Optional[pathlib.Path]': + """The stub file of a dotted module name under :data:`TYPINGS`, if there is one.""" + parts = module.split('.') + for candidate in (TYPINGS.joinpath(*parts, '__init__.pyi'), TYPINGS.joinpath(*parts[:-1], f'{parts[-1]}.pyi')): + if candidate.is_file(): + return candidate + return None + + +def _stub_names(path: 'pathlib.Path') -> 'set[str]': + """The top-level names a stub file declares.""" + names = set() + for statement in _parse(path).body: + if isinstance(statement, (ast.ClassDef, ast.FunctionDef)): + names.add(statement.name) + elif isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name): + names.add(statement.target.id) + elif isinstance(statement, ast.ImportFrom): + names.update(alias.asname or alias.name for alias in statement.names) + return names + + +def _untyped_imports() -> 'Iterator[tuple[str, str, Optional[str]]]': + """``(file, module, name)`` for each import of :data:`UNTYPED` in :mod:`pcapkit`. + + ``name`` is the name a ``from`` import takes, or :data:`None` for ``import``. + Imports anywhere in a module count, the function-level and the guarded ones + included. + + """ + pattern = re.compile(rf'\b(?:import|from)\s+(?:{"|".join(UNTYPED)})\b') + for path in sorted((ROOT / 'pcapkit').rglob('*.py')): + source = path.read_text(encoding='utf-8') + if not pattern.search(source): + continue + relative = path.relative_to(ROOT).as_posix() + for node in ast.walk(ast.parse(source, filename=str(path))): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.split('.')[0] in UNTYPED: + yield relative, alias.name, None + elif isinstance(node, ast.ImportFrom) and not node.level and node.module: + if node.module.split('.')[0] in UNTYPED: + for alias in node.names: + yield relative, node.module, alias.name + + +class EngineProtocolTests(unittest.TestCase): + """Each engine's ``_expkg`` is a protocol of its own, covering what it reads.""" + + def test_each_expkg_is_its_own_guarded_protocol(self) -> None: + owners = {} # type: dict[str, str] + for relative, engine in ENGINES.items(): + with self.subTest(engine=engine): + tree = _parse(ROOT / relative) + annotation = _expkg_annotation(_class(tree, engine)) + + self.assertIsNotNone(annotation, f'{engine} declares no _expkg annotation') + self.assertNotEqual(annotation, 'ModuleType', + f'{engine}._expkg is ModuleType, which types every attribute as Any') + self.assertIn(annotation, _guarded_protocols(tree), + f'{engine}._expkg is {annotation!r}, which is no Protocol defined in ' + f'the module-level TYPE_CHECKING block of {relative}') + self.assertNotIn(annotation, owners, + f'{engine} shares its _expkg protocol with {owners.get(annotation)}') + owners[str(annotation)] = engine + + def test_each_protocol_declares_what_its_engine_reads(self) -> None: + for relative, engine in ENGINES.items(): + tree = _parse(ROOT / relative) + cls = _class(tree, engine) + protocols = _guarded_protocols(tree) + paths = _read_paths(cls) + top = protocols.get(str(_expkg_annotation(cls))) + with self.subTest(engine=engine): + self.assertTrue(paths, f'{engine} reads nothing through _expkg, so this test checks nothing') + self.assertIsNotNone(top, f'{engine}._expkg is not annotated with a guarded Protocol') + if top is None: + continue + for path in sorted(paths): + with self.subTest(engine=engine, path='.'.join(path)): + protocol = top + for depth, name in enumerate(path): + members = _members(protocol) + self.assertIn(name, members, f'{engine} reads _expkg.{".".join(path[:depth + 1])}, ' + f'which {protocol.name} does not declare') + nested = protocols.get(members[name]) + if nested is None: + break + protocol = nested + + +class StubTests(unittest.TestCase): + """The stubs exist, are registered, and cover what :mod:`pcapkit` imports.""" + + def setUp(self) -> None: + # A source distribution ships neither, so is not a tree to check. A + # checkout always has mypy.ini, so a missing typings/ still fails. + if not MYPY_INI.is_file(): + self.skipTest(f'{MYPY_INI} is not present, e.g. in a source distribution') + + def test_the_stub_directory_is_in_mypy_path(self) -> None: + config = configparser.ConfigParser(interpolation=None) + config.read(MYPY_INI, encoding='utf-8') + # mypy splits the option on either separator, on every platform + entries = re.split(r'[,:]', config.get('mypy', 'mypy_path', fallback='')) + paths = {(ROOT / entry.strip().replace('$MYPY_CONFIG_FILE_DIR', str(ROOT))).resolve() + for entry in entries if entry.strip()} + + self.assertTrue(TYPINGS.is_dir(), f'{TYPINGS} is missing') + self.assertIn(TYPINGS.resolve(), paths, f'mypy.ini does not list {TYPINGS} in mypy_path: {paths}') + + def test_each_untyped_engine_package_is_stubbed(self) -> None: + for package in UNTYPED: + with self.subTest(package=package): + self.assertTrue((TYPINGS / package / '__init__.pyi').is_file(), + f'typings/{package}/__init__.pyi is missing') + + def test_the_stubs_cover_every_import_of_them(self) -> None: + imports = list(_untyped_imports()) + self.assertTrue(imports, 'no import of an untyped engine package found, so this test checks nothing') + for relative, module, name in imports: + with self.subTest(file=relative, module=module, name=name): + stub = _stub_for(module) + self.assertIsNotNone(stub, f'{relative} imports {module}, which typings/ has no stub for') + if name is not None: + self.assertTrue(name in _stub_names(stub) or _stub_for(f'{module}.{name}') is not None, + f'{relative} imports {name} from {module}, which {stub} does not declare') + + def test_each_stub_cites_the_version_it_was_written_against(self) -> None: + stubs = sorted(TYPINGS.rglob('*.pyi')) + self.assertTrue(stubs, 'typings/ holds no stubs') + for stub in stubs: + with self.subTest(stub=stub.relative_to(ROOT).as_posix()): + docstring = ast.get_docstring(_parse(stub)) or '' + self.assertRegex(docstring, r'\b\d+\.\d+', 'the stub names no version of what it describes') + + +if __name__ == '__main__': + unittest.main() diff --git a/typings/dpkt/__init__.pyi b/typings/dpkt/__init__.pyi new file mode 100644 index 000000000..4cfc00765 --- /dev/null +++ b/typings/dpkt/__init__.pyi @@ -0,0 +1,21 @@ +"""Type stubs for the part of ``dpkt`` that :mod:`pcapkit` uses (#1878). + +Written against dpkt 1.9.8, from its source as installed; dpkt ships no type +information of its own. Only the names :mod:`pcapkit` reads are declared, so +this is not a general-purpose stub: declare a name here before :mod:`pcapkit` +starts to use it. + +:file:`mypy.ini` registers this directory through ``mypy_path``. It is not part +of the wheel or the sdist. + +""" +from . import dpkt as dpkt +from . import ethernet as ethernet +from . import ip as ip +from . import ip6 as ip6 +from . import loopback as loopback +from . import pcap as pcap +from . import pcapng as pcapng +from . import tcp as tcp + +__version__: str diff --git a/typings/dpkt/dpkt.pyi b/typings/dpkt/dpkt.pyi new file mode 100644 index 000000000..889713638 --- /dev/null +++ b/typings/dpkt/dpkt.pyi @@ -0,0 +1,24 @@ +"""``dpkt.dpkt`` as of dpkt 1.9.8: the base class of every packet.""" +from typing import Any, ClassVar + +# builds each class's header fields from its ``__hdr__`` +class _MetaPacket(type): + def __new__(cls, clsname: str, clsbases: tuple[type, ...], clsdict: dict[str, Any]) -> _MetaPacket: ... + +class Packet(metaclass=_MetaPacket): + # Set by the metaclass on every subclass that declares ``__hdr__``, which is + # every protocol class; ``Packet`` itself, and a subclass without + # ``__hdr__``, have neither. + __hdr_fields__: ClassVar[list[str]] + __hdr_len__: ClassVar[int] + + # The payload: ``bytes``, a decoded ``Packet``, or a ``str`` where a layer + # leaves it so (``dpkt.vrrp.VRRP`` sets ``''``). Each layer decides. + data: Any + + def __init__(self, *args: bytes, **kwargs: Any) -> None: ... + def __len__(self) -> int: ... + def __bytes__(self) -> bytes: ... + def pack_hdr(self) -> bytes: ... + def pack(self) -> bytes: ... + def unpack(self, buf: bytes) -> None: ... diff --git a/typings/dpkt/ethernet.pyi b/typings/dpkt/ethernet.pyi new file mode 100644 index 000000000..c3ed8b7dc --- /dev/null +++ b/typings/dpkt/ethernet.pyi @@ -0,0 +1,4 @@ +"""``dpkt.ethernet`` as of dpkt 1.9.8: the Ethernet frame.""" +from . import dpkt + +class Ethernet(dpkt.Packet): ... diff --git a/typings/dpkt/ip.pyi b/typings/dpkt/ip.pyi new file mode 100644 index 000000000..d0754d4ee --- /dev/null +++ b/typings/dpkt/ip.pyi @@ -0,0 +1,15 @@ +"""``dpkt.ip`` as of dpkt 1.9.8: the IPv4 packet.""" +from . import dpkt + +class IP(dpkt.Packet): + len: int + id: int + p: int + src: bytes + dst: bytes + + # bit fields, properties over ``_v_hl`` and ``_flags_offset`` + hl: int + df: int + mf: int + offset: int diff --git a/typings/dpkt/ip6.pyi b/typings/dpkt/ip6.pyi new file mode 100644 index 000000000..8abf2cb36 --- /dev/null +++ b/typings/dpkt/ip6.pyi @@ -0,0 +1,24 @@ +"""``dpkt.ip6`` as of dpkt 1.9.8: the IPv6 packet and its extension headers.""" +from . import dpkt + +class IP6ExtensionHeader(dpkt.Packet): + # set by each subclass's ``unpack``: the octets the header occupies + length: int + +class IP6FragmentHeader(IP6ExtensionHeader): + nxt: int + id: int + + # bit fields, properties over ``_frag_off_resv_m`` + frag_off: int + m_flag: int + +class IP6(dpkt.Packet): + plen: int + src: bytes + dst: bytes + + # set by ``unpack``: each header by its protocol number, the last of a + # repeated type winning, and every header in the order it was parsed + extension_hdrs: dict[int, IP6ExtensionHeader] + all_extension_headers: list[IP6ExtensionHeader] diff --git a/typings/dpkt/loopback.pyi b/typings/dpkt/loopback.pyi new file mode 100644 index 000000000..88abefaf3 --- /dev/null +++ b/typings/dpkt/loopback.pyi @@ -0,0 +1,5 @@ +"""``dpkt.loopback`` as of dpkt 1.9.8: the BSD loopback header.""" +from . import dpkt + +class Loopback(dpkt.Packet): + family: int diff --git a/typings/dpkt/pcap.pyi b/typings/dpkt/pcap.pyi new file mode 100644 index 000000000..e4862691d --- /dev/null +++ b/typings/dpkt/pcap.pyi @@ -0,0 +1,32 @@ +"""``dpkt.pcap`` as of dpkt 1.9.8: the PCAP reader and its record headers.""" +from decimal import Decimal +from typing import IO, Iterator + +from . import dpkt + +class PktHdr(dpkt.Packet): + tv_sec: int + tv_usec: int + caplen: int + len: int + +class PktModHdr(dpkt.Packet): + tv_sec: int + tv_usec: int + caplen: int + len: int + +class Reader: + # ``self.__f`` and ``self.__ph``, by their mangled names: the stream, and + # the record header type the magic number selects + _Reader__f: IO[bytes] + _Reader__ph: type[PktHdr] | type[PktModHdr] + # ``Decimal('1E9')`` for a nanosecond file, else ``1E6`` + _divisor: float | Decimal + + def __init__(self, fileobj: IO[bytes]) -> None: ... + def datalink(self) -> int: ... + # the timestamp is ``tv_sec + tv_usec / _divisor``, so a ``Decimal`` for a + # nanosecond file + def __iter__(self) -> Iterator[tuple[float | Decimal, bytes]]: ... + def __next__(self) -> tuple[float | Decimal, bytes]: ... diff --git a/typings/dpkt/pcapng.pyi b/typings/dpkt/pcapng.pyi new file mode 100644 index 000000000..e762a410e --- /dev/null +++ b/typings/dpkt/pcapng.pyi @@ -0,0 +1,14 @@ +"""``dpkt.pcapng`` as of dpkt 1.9.8: the packet blocks.""" +from . import dpkt + +# dpkt derives these from a private ``_PcapngBlock``, itself a ``dpkt.Packet`` +class EnhancedPacketBlock(dpkt.Packet): + iface_id: int + ts_high: int + ts_low: int + pkt_len: int + pkt_data: bytes + +class EnhancedPacketBlockLE(EnhancedPacketBlock): ... +class PacketBlock(EnhancedPacketBlock): ... +class PacketBlockLE(PacketBlock): ... diff --git a/typings/dpkt/tcp.pyi b/typings/dpkt/tcp.pyi new file mode 100644 index 000000000..50b82f5b6 --- /dev/null +++ b/typings/dpkt/tcp.pyi @@ -0,0 +1,19 @@ +"""``dpkt.tcp`` as of dpkt 1.9.8: the TCP segment and its flag masks.""" +from . import dpkt + +TH_FIN: int +TH_SYN: int +TH_RST: int + +class TCP(dpkt.Packet): + sport: int + dport: int + seq: int + ack: int + + # bit fields, properties over ``_off_flags``; ``flags`` is nine bits wide + off: int + flags: int + + # ``unpack`` keeps the octets after the options undecoded + data: bytes diff --git a/typings/pcap/__init__.pyi b/typings/pcap/__init__.pyi new file mode 100644 index 000000000..26e4cdac8 --- /dev/null +++ b/typings/pcap/__init__.pyi @@ -0,0 +1,30 @@ +"""Type stubs for the part of ``pcap`` that :mod:`pcapkit` uses (#1878). + +More than one distribution installs this module. This is written against the +ones pcapkit drives, from the source each publishes on PyPI: pypcap 1.3.0, a +Cython extension, from its ``pcap.pyx``, and pcap-ct 1.3.0b3, a :mod:`ctypes` +reimplementation, from its ``pcap/_pcap.py``. Neither ships type information. A +signature here is one each of them accepts, so pcap-ct's extra ``datalink`` +argument to :class:`pcap` is left out. + +Only the names :mod:`pcapkit` reads are declared, so this is not a +general-purpose stub: declare a name here before :mod:`pcapkit` starts to use +it. + +:file:`mypy.ini` registers this directory through ``mypy_path``. It is not part +of the wheel or the sdist. + +""" + +__version__: str + +class pcap: + def __init__(self, name: str | None = None, snaplen: int = 65535, promisc: bool = True, + timeout_ms: int = 0, immediate: bool = False, rfmon: bool = False, + timestamp_in_ns: bool = False, buffer_size: int = 0) -> None: ... + def datalink(self) -> int: ... + def close(self) -> None: ... + def __iter__(self) -> pcap: ... + # an ``int`` count of nanoseconds instead of a ``float`` of seconds when + # opened with ``timestamp_in_ns=True`` + def __next__(self) -> tuple[float, bytes]: ... diff --git a/typings/pcapfile/__init__.pyi b/typings/pcapfile/__init__.pyi new file mode 100644 index 000000000..342fa5fc3 --- /dev/null +++ b/typings/pcapfile/__init__.pyi @@ -0,0 +1,20 @@ +"""Type stubs for the part of ``pcapfile`` that :mod:`pcapkit` uses (#1878). + +Written against pypcapfile 0.12.0, from the source it publishes on PyPI; it +ships no type information of its own. Only the names :mod:`pcapkit` reads are +declared, so this is not a general-purpose stub: declare a name here before +:mod:`pcapkit` starts to use it. + +The package itself imports none of the submodules below; each is an attribute +of it once imported. They are declared here because +:class:`~pcapkit.foundation.engines.pypcapfile.PyPCAPFile` imports all of them +before it reads one through the package, and mypy matches a module against a +:class:`~typing.Protocol` by what its stub declares. + +:file:`mypy.ini` registers this directory through ``mypy_path``. It is not part +of the wheel or the sdist. + +""" +from . import linklayer as linklayer +from . import savefile as savefile +from . import structs as structs diff --git a/typings/pcapfile/linklayer.pyi b/typings/pcapfile/linklayer.pyi new file mode 100644 index 000000000..c0caaba61 --- /dev/null +++ b/typings/pcapfile/linklayer.pyi @@ -0,0 +1,6 @@ +"""``pcapfile.linklayer`` as of pypcapfile 0.12.0: the link layer decoders.""" +from pcapfile.protocols.linklayer.ethernet import Ethernet + +# the decoder of a link layer type, or ``None`` when pypcapfile has none; only +# Ethernet has one +def clookup(ll_type: int) -> type[Ethernet] | None: ... diff --git a/typings/pcapfile/protocols/__init__.pyi b/typings/pcapfile/protocols/__init__.pyi new file mode 100644 index 000000000..3521c9026 --- /dev/null +++ b/typings/pcapfile/protocols/__init__.pyi @@ -0,0 +1 @@ +"""``pcapfile.protocols`` as of pypcapfile 0.12.0.""" diff --git a/typings/pcapfile/protocols/linklayer/__init__.pyi b/typings/pcapfile/protocols/linklayer/__init__.pyi new file mode 100644 index 000000000..e8e812781 --- /dev/null +++ b/typings/pcapfile/protocols/linklayer/__init__.pyi @@ -0,0 +1 @@ +"""``pcapfile.protocols.linklayer`` as of pypcapfile 0.12.0.""" diff --git a/typings/pcapfile/protocols/linklayer/ethernet.pyi b/typings/pcapfile/protocols/linklayer/ethernet.pyi new file mode 100644 index 000000000..801e1d89a --- /dev/null +++ b/typings/pcapfile/protocols/linklayer/ethernet.pyi @@ -0,0 +1,13 @@ +"""``pcapfile.protocols.linklayer.ethernet`` as of pypcapfile 0.12.0: the Ethernet frame.""" +import ctypes +from typing import Any + +class Ethernet(ctypes.Structure): + # ``c_char_p`` fields, the addresses as colon-separated hex text + dst: bytes + src: bytes + type: int + # not a ctypes field: the hexlified payload, or the layer it decoded into + payload: Any + + def __init__(self, packet: bytes, layers: int = 0) -> None: ... diff --git a/typings/pcapfile/protocols/network/__init__.pyi b/typings/pcapfile/protocols/network/__init__.pyi new file mode 100644 index 000000000..1b3d85357 --- /dev/null +++ b/typings/pcapfile/protocols/network/__init__.pyi @@ -0,0 +1 @@ +"""``pcapfile.protocols.network`` as of pypcapfile 0.12.0.""" diff --git a/typings/pcapfile/protocols/network/ip.pyi b/typings/pcapfile/protocols/network/ip.pyi new file mode 100644 index 000000000..bd9886d44 --- /dev/null +++ b/typings/pcapfile/protocols/network/ip.pyi @@ -0,0 +1,24 @@ +"""``pcapfile.protocols.network.ip`` as of pypcapfile 0.12.0: the IPv4 packet.""" +import ctypes +from typing import Any + +class IP(ctypes.Structure): + v: int + hl: int + tos: int + len: int + id: int + flags: int + off: int + ttl: int + p: int + sum: int + # ``c_char_p`` fields: the addresses as dotted-decimal text, and the options + # hexlified + src: bytes + dst: bytes + opt: bytes + # not a ctypes field: the hexlified payload, or the layer it decoded into + payload: Any + + def __init__(self, packet: bytes, layers: int = 0) -> None: ... diff --git a/typings/pcapfile/protocols/transport/__init__.pyi b/typings/pcapfile/protocols/transport/__init__.pyi new file mode 100644 index 000000000..73267e580 --- /dev/null +++ b/typings/pcapfile/protocols/transport/__init__.pyi @@ -0,0 +1 @@ +"""``pcapfile.protocols.transport`` as of pypcapfile 0.12.0.""" diff --git a/typings/pcapfile/protocols/transport/tcp.pyi b/typings/pcapfile/protocols/transport/tcp.pyi new file mode 100644 index 000000000..396d59490 --- /dev/null +++ b/typings/pcapfile/protocols/transport/tcp.pyi @@ -0,0 +1,15 @@ +"""``pcapfile.protocols.transport.tcp`` as of pypcapfile 0.12.0: the TCP segment.""" +import ctypes + +class TCP(ctypes.Structure): + src_port: int + dst_port: int + seqnum: int + acknum: int + # in octets, unlike the header field's 32-bit words + data_offset: int + rst: bool + syn: bool + fin: bool + + def __init__(self, packet: bytes, layers: int = 0) -> None: ... diff --git a/typings/pcapfile/savefile.pyi b/typings/pcapfile/savefile.pyi new file mode 100644 index 000000000..f24deda8a --- /dev/null +++ b/typings/pcapfile/savefile.pyi @@ -0,0 +1,22 @@ +"""``pcapfile.savefile`` as of pypcapfile 0.12.0: loading a savefile.""" +from typing import Iterable, Protocol + +from pcapfile.structs import __pcap_header__, pcap_packet + +class _Input(Protocol): + # what ``load_savefile`` reads of its ``input_file`` + @property + def name(self) -> str: ... + def read(self, size: int, /) -> bytes: ... + +class pcap_savefile: + header: __pcap_header__ + + # ``packets`` is a list, or with ``lazy=True`` a generator + def __init__(self, header: __pcap_header__, packets: Iterable[pcap_packet] | None = None) -> None: ... + +# Documented as returning ``None`` on failure, which cannot happen: +# ``_load_savefile_header`` raises ``InvalidHeader`` for any header the later +# check would reject. +def load_savefile(input_file: _Input, layers: int = 0, verbose: bool = False, + lazy: bool = False) -> pcap_savefile: ... diff --git a/typings/pcapfile/structs.pyi b/typings/pcapfile/structs.pyi new file mode 100644 index 000000000..3e40c9f41 --- /dev/null +++ b/typings/pcapfile/structs.pyi @@ -0,0 +1,23 @@ +"""``pcapfile.structs`` as of pypcapfile 0.12.0: the savefile header and the packet.""" +import ctypes +from typing import Any + +# A ctypes field reads back as a Python value: ``c_uint`` as ``int``, +# ``c_bool`` as ``bool``, and ``c_char_p`` as ``bytes``, never ``None`` here as +# ``savefile`` always sets it. +class __pcap_header__(ctypes.Structure): + ll_type: int + byteorder: bytes + ns_resolution: bool + +class pcap_packet(ctypes.Structure): + header: ctypes._Pointer[__pcap_header__] + timestamp: int + timestamp_us: int + capture_len: int + packet_len: int + # not a ctypes field: the hexlified frame, or the layer it decoded into + packet: Any + + def __init__(self, header: ctypes._Pointer[__pcap_header__], timestamp: int, + timestamp_us: int, capture_len: int, packet_len: int, packet: Any) -> None: ... diff --git a/typings/pyshark/__init__.pyi b/typings/pyshark/__init__.pyi new file mode 100644 index 000000000..a42155046 --- /dev/null +++ b/typings/pyshark/__init__.pyi @@ -0,0 +1,12 @@ +"""Type stubs for the part of ``pyshark`` that :mod:`pcapkit` uses (#1878). + +Written against pyshark 0.6, from its source as installed; pyshark ships no +type information of its own. Only the names :mod:`pcapkit` reads are declared, +so this is not a general-purpose stub: declare a name here before +:mod:`pcapkit` starts to use it. + +:file:`mypy.ini` registers this directory through ``mypy_path``. It is not part +of the wheel or the sdist. + +""" +from pyshark.capture.file_capture import FileCapture as FileCapture diff --git a/typings/pyshark/capture/__init__.pyi b/typings/pyshark/capture/__init__.pyi new file mode 100644 index 000000000..cf07bbbd3 --- /dev/null +++ b/typings/pyshark/capture/__init__.pyi @@ -0,0 +1 @@ +"""``pyshark.capture`` as of pyshark 0.6.""" diff --git a/typings/pyshark/capture/file_capture.pyi b/typings/pyshark/capture/file_capture.pyi new file mode 100644 index 000000000..abac6929d --- /dev/null +++ b/typings/pyshark/capture/file_capture.pyi @@ -0,0 +1,21 @@ +"""``pyshark.capture.file_capture`` as of pyshark 0.6: the capture read from a file.""" +import asyncio +import os +from typing import Any + +from pyshark.packet.packet import Packet + +# pyshark derives this from ``pyshark.capture.capture.Capture``, which is where +# ``close`` is defined +class FileCapture: + def __init__(self, input_file: str | os.PathLike[str] | None = None, keep_packets: bool = True, + display_filter: str | None = None, only_summaries: bool = False, + decryption_key: str | None = None, encryption_type: str = 'wpa-pwk', + decode_as: dict[str, str] | None = None, disable_protocol: str | None = None, + tshark_path: str | None = None, override_prefs: dict[str, str] | None = None, + use_json: bool = False, use_ek: bool = False, output_file: str | None = None, + include_raw: bool = False, eventloop: asyncio.AbstractEventLoop | None = None, + custom_parameters: dict[str, Any] | list[str] | None = None, + debug: bool = False) -> None: ... + def next(self) -> Packet: ... + def close(self) -> None: ... diff --git a/typings/pyshark/packet/__init__.pyi b/typings/pyshark/packet/__init__.pyi new file mode 100644 index 000000000..0f3df4ca5 --- /dev/null +++ b/typings/pyshark/packet/__init__.pyi @@ -0,0 +1 @@ +"""``pyshark.packet`` as of pyshark 0.6.""" diff --git a/typings/pyshark/packet/layers/__init__.pyi b/typings/pyshark/packet/layers/__init__.pyi new file mode 100644 index 000000000..4801b3fbc --- /dev/null +++ b/typings/pyshark/packet/layers/__init__.pyi @@ -0,0 +1 @@ +"""``pyshark.packet.layers`` as of pyshark 0.6.""" diff --git a/typings/pyshark/packet/layers/base.pyi b/typings/pyshark/packet/layers/base.pyi new file mode 100644 index 000000000..50dfedad3 --- /dev/null +++ b/typings/pyshark/packet/layers/base.pyi @@ -0,0 +1,11 @@ +"""``pyshark.packet.layers.base`` as of pyshark 0.6: the base class of every layer.""" +from typing import Any + +class BaseLayer: + def __init__(self, layer_name: str) -> None: ... + @property + def layer_name(self) -> str: ... + @property + def field_names(self) -> list[str]: ... + # a field by name, or ``AttributeError`` when the layer has no such field + def __getattr__(self, item: str) -> Any: ... diff --git a/typings/pyshark/packet/packet.pyi b/typings/pyshark/packet/packet.pyi new file mode 100644 index 000000000..0f827c47d --- /dev/null +++ b/typings/pyshark/packet/packet.pyi @@ -0,0 +1,19 @@ +"""``pyshark.packet.packet`` as of pyshark 0.6: the packet, a list of layers.""" +from typing import Any + +from pyshark.packet.layers.base import BaseLayer + +class Packet: + layers: list[BaseLayer] + # the constructor defaults it to ``None``, but every tshark output parser + # passes the frame's layer + frame_info: BaseLayer + # a PDML field value, a ``str``, or ``None`` where the record has none; the + # EK and JSON parsers pass an ``int`` + number: int | str | None + # PDML's raw timestamp text, or the EK or JSON parser's epoch value + sniff_timestamp: Any + + def __init__(self, layers: list[BaseLayer] | None = None, frame_info: BaseLayer | None = None, + number: int | str | None = None, length: Any = None, captured_length: Any = None, + sniff_time: Any = None, interface_captured: Any = None) -> None: ... diff --git a/typings/pyshark/tshark/__init__.pyi b/typings/pyshark/tshark/__init__.pyi new file mode 100644 index 000000000..937ed6983 --- /dev/null +++ b/typings/pyshark/tshark/__init__.pyi @@ -0,0 +1 @@ +"""``pyshark.tshark`` as of pyshark 0.6.""" diff --git a/typings/pyshark/tshark/tshark.pyi b/typings/pyshark/tshark/tshark.pyi new file mode 100644 index 000000000..5155ef6bc --- /dev/null +++ b/typings/pyshark/tshark/tshark.pyi @@ -0,0 +1,4 @@ +"""``pyshark.tshark.tshark`` as of pyshark 0.6: locating the tshark binary.""" + +# raises ``TSharkNotFoundException`` when no candidate path exists +def get_process_path(tshark_path: str | None = None, process_name: str = 'tshark') -> str: ...