Skip to content

Commit d7c505f

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 arguments whose value equals the name, whose *_namespace companion is None, and whose default is an enum member, and uses that default's enum class. If several arguments match, every candidate enum must have the name with the same value; otherwise ProtocolNotImplemented asks for an explicit *_namespace. 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 11ae68e commit d7c505f

2 files changed

Lines changed: 242 additions & 0 deletions

File tree

‎pcapkit/protocols/protocol.py‎

Lines changed: 92 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,25 @@ 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. If several arguments were given the
1607+
same name, every one of their enumerations must have the name
1608+
and map it to the same value; otherwise
1609+
:exc:`~pcapkit.utilities.exceptions.ProtocolNotImplemented` is
1610+
raised, asking for an explicit ``*_namespace`` (see
1611+
:meth:`_make_index_namespace`).
1612+
16011613
"""
16021614
if isinstance(name, (enum.Enum, aenum.Enum)):
16031615
index = cast('int', name.value)
16041616
elif isinstance(name, int):
16051617
index = name
16061618
else: # name is str
1619+
if namespace is None:
1620+
namespace = cls._make_index_namespace(name, inspect.currentframe())
16071621
try:
16081622
if isinstance(namespace, type) and issubclass(namespace, (enum.IntEnum, aenum.IntEnum)):
16091623
index = cast('int', namespace[name].value)
@@ -1631,6 +1645,84 @@ def _make_index(cls, name: 'str | int | StdlibEnum | AenumEnum', default: 'Optio
16311645
return cls._make_pack(index, size=size, signed=signed, lilendian=lilendian)
16321646
return index
16331647

1648+
@classmethod
1649+
def _make_index_namespace(cls, name: 'str',
1650+
frame: 'Optional[FrameType]') -> 'Optional[Type[StdlibEnum] | Type[AenumEnum]]':
1651+
"""Infer the enumeration namespace of ``name`` for :meth:`_make_index`.
1652+
1653+
The caller of :meth:`_make_index` (``frame.f_back``) is looked up
1654+
among the methods of ``cls``. An argument ``arg`` of that method
1655+
matches if it is accompanied by an ``arg_namespace`` argument that
1656+
is :data:`None`, its current value is a :obj:`str` equal to
1657+
``name``, and its default
1658+
value is an :class:`~enum.IntEnum` (or :class:`aenum.IntEnum`)
1659+
member. The candidate namespace of a match is the class of that
1660+
default value.
1661+
1662+
Args:
1663+
name: member name given to :meth:`_make_index`
1664+
frame: frame of :meth:`_make_index`
1665+
1666+
Returns:
1667+
The candidate namespace if exactly one argument matches. If
1668+
several match, any candidate, provided every candidate has
1669+
``name`` as a member and maps it to the same value.
1670+
:data:`None` if the caller is not found, nothing matches, or no
1671+
candidate has ``name`` as a member.
1672+
1673+
Raises:
1674+
ProtocolNotImplemented: If several arguments match and their
1675+
candidates disagree on ``name``: some lack it, or they map
1676+
it to different values.
1677+
1678+
"""
1679+
caller = frame.f_back if frame is not None else None
1680+
if caller is None:
1681+
return None
1682+
try:
1683+
code = caller.f_code
1684+
values = caller.f_locals
1685+
1686+
func = None
1687+
for klass in cls.__mro__:
1688+
for attr in vars(klass).values():
1689+
attr = getattr(attr, '__func__', attr)
1690+
if getattr(attr, '__code__', None) is code:
1691+
func = attr
1692+
break
1693+
if func is not None:
1694+
break
1695+
if func is None:
1696+
return None
1697+
1698+
params = inspect.signature(func).parameters
1699+
found = {} # type: dict[str, Type[StdlibEnum] | Type[AenumEnum]]
1700+
for arg, param in params.items():
1701+
value = values.get(arg)
1702+
if f'{arg}_namespace' not in params or not isinstance(value, str) or value != name:
1703+
continue
1704+
# an explicit namespace resolves that argument by itself
1705+
if values.get(f'{arg}_namespace') is not None:
1706+
continue
1707+
if isinstance(param.default, (enum.IntEnum, aenum.IntEnum)):
1708+
found[arg] = type(param.default)
1709+
finally:
1710+
del caller, frame
1711+
1712+
if len(found) == 1:
1713+
return next(iter(found.values()))
1714+
1715+
known = {arg: int(space[name]) for arg, space in found.items() if name in space.__members__}
1716+
if not known:
1717+
return None
1718+
if len(known) < len(found) or len(set(known.values())) > 1:
1719+
matches = ', '.join(f'{space.__name__}.{name} = {known[arg]}' if arg in known
1720+
else f'{space.__name__} has no {name!r}' for arg, space in found.items())
1721+
hint = ' or '.join(f'{arg}_namespace=' for arg in found)
1722+
raise ProtocolNotImplemented(f'{cls.__name__}: ambiguous member name {name!r} given to '
1723+
f'{", ".join(found)} ({matches}); pass {hint} explicitly')
1724+
return next(iter(found.values()))
1725+
16341726
@classmethod
16351727
def _make_data(cls, data: 'Data') -> 'dict[str, Any]':
16361728
"""Create key-value pairs from ``data`` for protocol construction.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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_shared_name_with_one_meaning_resolves(self) -> None:
97+
ipv4 = _attr('pcapkit.protocols.internet.ipv4.IPv4')
98+
tos_del = _attr('pcapkit.const.ipv4.tos_del.ToSDelay')
99+
tos_thr = _attr('pcapkit.const.ipv4.tos_thr.ToSThroughput')
100+
tos_rel = _attr('pcapkit.const.ipv4.tos_rel.ToSReliability')
101+
102+
expected = ipv4(tos_del=tos_del.NORMAL, tos_thr=tos_thr.NORMAL).data
103+
self.assertEqual(ipv4(tos_del='NORMAL', tos_thr='NORMAL').data, expected)
104+
# an equal but distinct string object, so the match is not by identity
105+
self.assertEqual(ipv4(tos_del='NORMAL', tos_thr=''.join(['NOR', 'MAL'])).data, expected)
106+
107+
expected = ipv4(tos_thr=tos_thr.HIGH, tos_rel=tos_rel.HIGH).data
108+
self.assertEqual(ipv4(tos_thr='HIGH', tos_rel='HIGH').data, expected)
109+
self.assertNotEqual(ipv4().data, expected)
110+
111+
def test_shared_name_with_two_meanings_raises(self) -> None:
112+
enum = importlib.import_module('enum')
113+
exc = _attr('pcapkit.utilities.exceptions.ProtocolNotImplemented')
114+
protocol = _attr('pcapkit.protocols.protocol.ProtocolBase')
115+
116+
left = enum.IntEnum('Left', {'Shared': 1})
117+
right = enum.IntEnum('Right', {'Shared': 2})
118+
119+
class Pair(protocol): # type: ignore[misc,valid-type]
120+
def make_pair(self, a: 'Any' = left.Shared, a_namespace: 'Any' = None,
121+
b: 'Any' = right.Shared, b_namespace: 'Any' = None) -> 'int':
122+
return self._make_index(a, namespace=a_namespace)
123+
124+
with self.assertRaisesRegex(exc, r"ambiguous member name 'Shared'.*"
125+
r"pass a_namespace= or b_namespace= explicitly"):
126+
Pair.make_pair(Pair, a='Shared', b='Shared')
127+
# an explicit namespace on the other argument removes the ambiguity
128+
self.assertEqual(Pair.make_pair(Pair, a='Shared', b='Shared', b_namespace=right), 1)
129+
130+
# a real make(): ``LOW`` is a ToSDelay member but not a ToSThroughput one
131+
ipv4 = _attr('pcapkit.protocols.internet.ipv4.IPv4')
132+
with self.assertRaisesRegex(exc, r"ambiguous member name 'LOW'.*"
133+
r"pass tos_del_namespace= or tos_thr_namespace= explicitly"):
134+
ipv4(tos_del='LOW', tos_thr='LOW')
135+
136+
def test_without_inferable_enum_still_raises(self) -> None:
137+
exc = _attr('pcapkit.utilities.exceptions.ProtocolNotImplemented')
138+
protocol = _attr('pcapkit.protocols.protocol.ProtocolBase')
139+
# called outside any ``make()``, so there is no argument to infer from
140+
with self.assertRaises(exc):
141+
protocol._make_index('Internet_Protocol_version_6')
142+
143+
# ``status`` defaults to ``None``, so its enum cannot be inferred
144+
http = _attr('pcapkit.protocols.application.httpv1.HTTP')
145+
with self.assertRaises(exc):
146+
http(status='OK')
147+
148+
149+
if __name__ == '__main__':
150+
unittest.main()

0 commit comments

Comments
 (0)