Skip to content

Commit bb5c9a8

Browse files
committed
fix(protocols): make() rejects an enum member name given as str unless a namespace is passed
Protocol._make_index now infers the namespace for a str member name when none is given: it finds the calling make() method on the class, picks the argument whose value is the name and which has an *_namespace companion, and uses the enum class of that argument's default value. int, enum members and explicit namespaces take the same path as before. Arguments whose default is not an enum member (None defaults and loop variables over list arguments) keep the old behaviour. Closes #1210
1 parent 630f0a5 commit bb5c9a8

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

‎pcapkit/protocols/protocol.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949

5050
if TYPE_CHECKING:
5151
from enum import IntEnum as StdlibEnum
52+
from types import FrameType
5253
from typing import IO, Any, Callable, DefaultDict, Optional, Type
5354

5455
from aenum import IntEnum as AenumEnum
@@ -1598,12 +1599,20 @@ def _make_index(cls, name: 'str | int | StdlibEnum | AenumEnum', default: 'Optio
15981599
ProtocolNotImplemented: If ``name`` is **NOT** in ``namespace``
15991600
and ``default`` is :data:`None`.
16001601
1602+
Note:
1603+
If ``name`` is a :obj:`str` and ``namespace`` is :data:`None`,
1604+
the namespace is inferred from the calling ``make`` method: it
1605+
is the enumeration class of the default value of the argument
1606+
that ``name`` was passed as (see :meth:`_make_index_namespace`).
1607+
16011608
"""
16021609
if isinstance(name, (enum.Enum, aenum.Enum)):
16031610
index = cast('int', name.value)
16041611
elif isinstance(name, int):
16051612
index = name
16061613
else: # name is str
1614+
if namespace is None:
1615+
namespace = cls._make_index_namespace(name, inspect.currentframe())
16071616
try:
16081617
if isinstance(namespace, type) and issubclass(namespace, (enum.IntEnum, aenum.IntEnum)):
16091618
index = cast('int', namespace[name].value)
@@ -1631,6 +1640,60 @@ def _make_index(cls, name: 'str | int | StdlibEnum | AenumEnum', default: 'Optio
16311640
return cls._make_pack(index, size=size, signed=signed, lilendian=lilendian)
16321641
return index
16331642

1643+
@classmethod
1644+
def _make_index_namespace(cls, name: 'str',
1645+
frame: 'Optional[FrameType]') -> 'Optional[Type[StdlibEnum] | Type[AenumEnum]]':
1646+
"""Infer the enumeration namespace of ``name`` for :meth:`_make_index`.
1647+
1648+
The caller of :meth:`_make_index` (``frame.f_back``) is looked up
1649+
among the methods of ``cls``. An argument ``arg`` of that method
1650+
matches if it is accompanied by an ``arg_namespace`` argument, its
1651+
current value is ``name`` itself, and its default value is an
1652+
:class:`~enum.IntEnum` (or :class:`aenum.IntEnum`) member. The
1653+
namespace is the class of that default value.
1654+
1655+
Args:
1656+
name: member name given to :meth:`_make_index`
1657+
frame: frame of :meth:`_make_index`
1658+
1659+
Returns:
1660+
The inferred enumeration class, or :data:`None` if the caller
1661+
is not found, or no argument (or more than one enumeration
1662+
class) matches.
1663+
1664+
"""
1665+
caller = frame.f_back if frame is not None else None
1666+
if caller is None:
1667+
return None
1668+
try:
1669+
code = caller.f_code
1670+
values = caller.f_locals
1671+
1672+
func = None
1673+
for klass in cls.__mro__:
1674+
for attr in vars(klass).values():
1675+
attr = getattr(attr, '__func__', attr)
1676+
if getattr(attr, '__code__', None) is code:
1677+
func = attr
1678+
break
1679+
if func is not None:
1680+
break
1681+
if func is None:
1682+
return None
1683+
1684+
params = inspect.signature(func).parameters
1685+
found = set() # type: set[Type[StdlibEnum] | Type[AenumEnum]]
1686+
for arg, param in params.items():
1687+
if f'{arg}_namespace' not in params or values.get(arg) is not name:
1688+
continue
1689+
if isinstance(param.default, (enum.IntEnum, aenum.IntEnum)):
1690+
found.add(type(param.default))
1691+
finally:
1692+
del caller, frame
1693+
if len(found) == 1:
1694+
return found.pop()
1695+
return None
1696+
16341697
@classmethod
16351698
def _make_data(cls, data: 'Data') -> 'dict[str, Any]':
16361699
"""Create key-value pairs from ``data`` for protocol construction.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# -*- coding: utf-8 -*-
2+
"""``make()`` resolves an enum member name given as :obj:`str`.
3+
4+
GitHub issue #1210: the ``make()`` signatures declare enum arguments as
5+
``Enum | StdlibEnum | AenumEnum | str | int``, but
6+
:meth:`~pcapkit.protocols.protocol.ProtocolBase._make_index` raised
7+
:exc:`~pcapkit.utilities.exceptions.ProtocolNotImplemented` for a :obj:`str`
8+
unless a ``*_namespace`` was passed. A member name now resolves against the
9+
enumeration class of the argument's default value, and builds the same bytes
10+
as the member itself and as its integer value.
11+
12+
Every case builds its own packet in memory and reads no capture. Classes are
13+
imported inside each test, after
14+
:func:`~tests._support.reimport_once_per_class`, so they belong to the live
15+
:mod:`pcapkit` import.
16+
17+
"""
18+
19+
import importlib
20+
import unittest
21+
from typing import TYPE_CHECKING
22+
23+
from tests._support import reimport_once_per_class
24+
25+
if TYPE_CHECKING:
26+
from typing import Any
27+
28+
29+
def _attr(path: 'str') -> 'Any':
30+
module, _, name = path.rpartition('.')
31+
return getattr(importlib.import_module(module), name)
32+
33+
34+
def _tcp_alternate_checksum(**kwargs: 'Any') -> 'Any':
35+
option = _attr('pcapkit.const.tcp.option.Option')
36+
return _attr('pcapkit.protocols.transport.tcp.TCP')(
37+
options=[(option.TCP_Alternate_Checksum_Request, kwargs)],
38+
)
39+
40+
41+
#: One ``make()`` per protocol family:
42+
#: (protocol class or builder, argument, enum class, non-default member).
43+
CASES = {
44+
'link/ethernet': ('pcapkit.protocols.link.ethernet.Ethernet', 'type',
45+
'pcapkit.const.reg.ethertype.EtherType', 'Internet_Protocol_version_6'),
46+
'link/l2tpv2': ('pcapkit.protocols.link.l2tpv2.L2TPv2', 'type',
47+
'pcapkit.const.l2tp.type.Type', 'Control'),
48+
'internet/ipv4': ('pcapkit.protocols.internet.ipv4.IPv4', 'protocol',
49+
'pcapkit.const.reg.transtype.TransType', 'TCP'),
50+
'transport/tcp': (_tcp_alternate_checksum, 'algorithm',
51+
'pcapkit.const.tcp.checksum.Checksum', 'Checksum_16_bit_Fletcher_s_algorithm'),
52+
'application/ospf': ('pcapkit.protocols.application.ospf.OSPF', 'type',
53+
'pcapkit.const.ospf.packet.Packet', 'Database_Description'),
54+
'misc/pcap': ('pcapkit.protocols.misc.pcap.header.Header', 'network',
55+
'pcapkit.const.reg.linktype.LinkType', 'RAW'),
56+
}
57+
58+
59+
class TestMakeIndexStr(unittest.TestCase):
60+
"""Pin member-name resolution in ``_make_index`` across families."""
61+
62+
def setUp(self) -> None:
63+
reimport_once_per_class(self)
64+
65+
def test_member_name_matches_member_and_int(self) -> None:
66+
for case, (maker, arg, enum_path, member) in CASES.items():
67+
with self.subTest(case=case):
68+
build = _attr(maker) if isinstance(maker, str) else maker
69+
value = getattr(_attr(enum_path), member)
70+
71+
expected = build(**{arg: value}).data
72+
self.assertEqual(build(**{arg: member}).data, expected)
73+
self.assertEqual(build(**{arg: int(value)}).data, expected)
74+
# the member is not the default, so the name was not ignored
75+
self.assertNotEqual(build().data, expected)
76+
77+
def test_unknown_name_still_raises(self) -> None:
78+
exc = _attr('pcapkit.utilities.exceptions.ProtocolNotImplemented')
79+
ethernet = _attr('pcapkit.protocols.link.ethernet.Ethernet')
80+
with self.assertRaises(exc):
81+
ethernet(type='No_Such_EtherType')
82+
83+
def test_unknown_name_falls_back_to_default(self) -> None:
84+
ethernet = _attr('pcapkit.protocols.link.ethernet.Ethernet')
85+
proto = ethernet(type='No_Such_EtherType', type_default=0x1234)
86+
self.assertEqual(proto.data[12:14], b'\x12\x34')
87+
88+
def test_explicit_namespace_wins(self) -> None:
89+
ethernet = _attr('pcapkit.protocols.link.ethernet.Ethernet')
90+
# the name is also an EtherType member, but the namespace decides
91+
proto = ethernet(type='Internet_Protocol_version_6',
92+
type_namespace={'Internet_Protocol_version_6': 0x1234},
93+
type_reversed=True)
94+
self.assertEqual(proto.data[12:14], b'\x12\x34')
95+
96+
def test_without_inferable_enum_still_raises(self) -> None:
97+
exc = _attr('pcapkit.utilities.exceptions.ProtocolNotImplemented')
98+
protocol = _attr('pcapkit.protocols.protocol.ProtocolBase')
99+
# called outside any ``make()``, so there is no argument to infer from
100+
with self.assertRaises(exc):
101+
protocol._make_index('Internet_Protocol_version_6')
102+
103+
# ``status`` defaults to ``None``, so its enum cannot be inferred
104+
http = _attr('pcapkit.protocols.application.httpv1.HTTP')
105+
with self.assertRaises(exc):
106+
http(status='OK')
107+
108+
109+
if __name__ == '__main__':
110+
unittest.main()

0 commit comments

Comments
 (0)