Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Preceded by `1.5.0a1` (2026-09-15), `1.5.0b1` to `1.5.0b3` (all 2026-09-19) and
- `FEATCode.get()` matches case-insensitively, as [RFC 5797 Section 2](https://datatracker.ietf.org/doc/html/rfc5797#section-2) defines FEAT codes; the registry-wide case-sensitivity audit found no other registry that needed to fold ([#903](https://github.com/JarryShaw/PyPCAPKit/issues/903), [#913](https://github.com/JarryShaw/PyPCAPKit/pull/913)).
- `register` refuses what `get` refuses. `TCP.register(70000, 'x')` and `Hardware.register(70000, 'x')` minted a member that `get` then returned, because no registry overrode the `_validate_value` hook `register` calls. Each generated registry, `AppType` included, now overrides it with the check its `_missing_` makes, raising `EnumValueError`. `get(value, default)` on a value the hook refuses still returns `default` and logs nothing ([#1792](https://github.com/JarryShaw/PyPCAPKit/issues/1792), [#1818](https://github.com/JarryShaw/PyPCAPKit/pull/1818), [#1816](https://github.com/JarryShaw/PyPCAPKit/issues/1816), [#1823](https://github.com/JarryShaw/PyPCAPKit/pull/1823); the quiet miss is [#1845](https://github.com/JarryShaw/PyPCAPKit/issues/1845), [#1873](https://github.com/JarryShaw/PyPCAPKit/pull/1873), under `pcapkit.corekit`).
- `Command.get()` refuses a key that is not a `str` with `EnumValueError`, as `Method.get()` does. `get(b'\x01')` minted a member keyed by `bytes`, and `get(-1)` raised `AttributeError` whatever its `default` ([#1824](https://github.com/JarryShaw/PyPCAPKit/issues/1824), [#1826](https://github.com/JarryShaw/PyPCAPKit/pull/1826)).
- IANA's port spans that name no transport answer `AppType.get()` with the asking registry's transport rather than `TransportProtocol.undefined`, as its `unknown` fallback for a port in no span already did: `TCP.get(8012)` is `unassigned [tcp]`. A port IANA lists on other transports only stays `unknown`: 8011 is `reserved [tcp]` and `bier-echo [udp]`, and `unknown [dccp]` and `unknown [sctp]`. Lookups still mint nothing ([#860](https://github.com/JarryShaw/PyPCAPKit/issues/860)) and an out-of-range port still raises ([#2048](https://github.com/JarryShaw/PyPCAPKit/issues/2048), [#2052](https://github.com/JarryShaw/PyPCAPKit/pull/2052)).

### pcapkit.corekit

Expand Down
8 changes: 8 additions & 0 deletions docs/source/changelog/1.5.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,14 @@ Fixed
as :meth:`.Method.get` does. ``get(b'\x01')`` minted a member keyed by ``bytes``, and
``get(-1)`` raised ``AttributeError`` whatever its ``default`` (:issue:`1824`,
:pr:`1826`).
* IANA's port spans that name no transport answer
:meth:`.AppType.get` with the asking registry's transport rather than
``TransportProtocol.undefined``, as its ``unknown`` fallback for a port in no span
already did: ``TCP.get(8012)`` is ``unassigned [tcp]``. A port IANA lists on other
transports only stays ``unknown``: 8011 is ``reserved [tcp]`` and
``bier-echo [udp]``, and ``unknown [dccp]`` and ``unknown [sctp]``. Lookups still
mint nothing (:issue:`860`) and an out-of-range port still raises (:issue:`2048`,
:pr:`2052`).

pcapkit.corekit
---------------
Expand Down
1,542 changes: 774 additions & 768 deletions pcapkit/const/reg/apptype/apptype.py

Large diffs are not rendered by default.

22 changes: 16 additions & 6 deletions pcapkit/vendor/reg/apptype/apptype.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,9 @@ class TransportProtocol(EnumLookup, IntEnum):

#: No transport protocol. ``TransportProtocol(0) is undefined`` and
#: ``bool(undefined)`` is ``False``; it is the ``proto`` sentinel default
#: for ``__transport__``, ``__new__``, ``get`` and ``get_all``, and what
#: the base registry's ``_missing_`` extends unassigned/reserved rows from.
#: for ``__transport__``, ``__new__``, ``get`` and ``get_all``. The base
#: registry's ``_missing_`` answered its unassigned and reserved spans with
#: it until :issue:`2048`; they answer with the asking registry's transport.
undefined = cast('TransportProtocol', 0)

#: Transmission Control Protocol.
Expand Down Expand Up @@ -606,7 +607,8 @@ def get(cls, key: 'int', *, # type: ignore[override] # pylint: disable=argument
not added to the registry (:issue:`860`). Its name is the label of
the IANA span holding the port, whatever that label is --
``reserved``, ``unassigned``, or a service name such as ``swx`` --
or ``unknown`` when no span holds it.
or ``unknown`` when no span holds it, either way carrying this
registry's own transport (:issue:`2048`).

Raises:
ValueError: If called on a class that holds no members, i.e. on
Expand Down Expand Up @@ -653,7 +655,10 @@ def get(cls, key: 'int', *, # type: ignore[override] # pylint: disable=argument
# access could reach) -- and this was the second, independent mint site
# on this method, distinct from the one inside :meth:`_missing_` itself:
# neither is a value anyone asked to be named, so both build an
# unregistered member instead of registering one.
# unregistered member instead of registering one. A port IANA lists on
# other transports only is in no span on this one: 8011, reserved on
# TCP and ``bier-echo`` on UDP, answers ``unknown`` on DCCP and SCTP
# (#2048).
ret = owner._missing_(key)
if ret is None:
ret = owner._unregistered_member(key, 'unknown', owner.__transport__)
Comment thread
JarryShaw marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -964,7 +969,8 @@ def _missing_(cls, value: 'int') -> 'Optional[{NAME}]':
if cls.__registry__ is None:
raise ValueError(f'{{value!r}} is not a valid {{cls.__name__}}')
# NOTE: most spans are IANA's unassigned and reserved markers, which name
# no transport protocol and so answer every registry. A span that does name
# no transport protocol and so answer every registry, each with its own
# transport (#2048). A span that does name
# one tests ``cls.__transport__`` and answers that registry alone --
# GitHub issue #760, where source order decided instead and a UDP lookup in
# 6000-6063 came back carrying ``tcp``. A registry a named span excludes
Expand Down Expand Up @@ -1278,6 +1284,10 @@ def records(self, data: 'list[str]') -> 'tuple[OrderedDict[str, Record], list[st
# no test and keeps answering every registry.
flag = self.flag([proto])
claim = '' if proto == 'undefined' else f' and cls.__transport__ is {flag}'
# NOTE: GitHub issue #2048. A span naming no transport protocol
# answers each registry with that registry's own, rather than
# with ``undefined``.
answer = 'cls.__transport__' if proto == 'undefined' else flag

# NOTE: GitHub issue #860. A port in this span resolves to an
# unregistered member rather than an extend_enum(...)-ed,
Expand All @@ -1288,7 +1298,7 @@ def records(self, data: 'list[str]') -> 'tuple[OrderedDict[str, Record], list[st
# range.
miss.append(f'if {start} <= value <= {stop}{claim}:')
miss.append(f' #: {cmmt}')
miss.append(f' return cls._unregistered_member(value, {svc!r}, {flag})')
miss.append(f' return cls._unregistered_member(value, {svc!r}, {answer})')

return line, miss

Expand Down
18 changes: 11 additions & 7 deletions tests/const/test_const_apptype_split_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,9 +670,11 @@ def test_every_transport_named_span_is_claimed_by_one_registry(self) -> None:
it returns silently, because a shadowed branch is unreachable rather than
wrong. This asserts the invariant over every branch instead of over the two
spans that happen to collide: a branch minting a member for a named
transport protocol is claimed by that registry, and one minting for
``undefined`` claims nothing, since IANA's unassigned and reserved markers
belong to whichever registry was asked.
transport protocol is claimed by that registry, and one answering with
``cls.__transport__`` claims nothing, since IANA's unassigned and reserved
markers belong to whichever registry was asked. Such a branch answered
``undefined`` until GitHub issue #2048, which made it answer with the
asking registry's own transport, so none answers ``undefined`` now.

"Every branch" is checked against the same source parsed with :mod:`ast`:
the pattern has to find one branch per ``_unregistered_member`` call in
Expand All @@ -698,21 +700,23 @@ def test_every_transport_named_span_is_claimed_by_one_registry(self) -> None:
# the call's own name changed here and not the shape of what follows.
branches = re.findall(r'\n if (.+?):\n #:.*?\n '
r"return cls\._unregistered_member\(value, .+?, "
r'TransportProtocol\.(\w+)\)',
r'(?:TransportProtocol\.(\w+)|cls\.__transport__)\)',
source)
function = ast.parse(textwrap.dedent(source)).body[0]
calls = [node for node in ast.walk(function)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr == '_unregistered_member']
self.assertNotEqual(branches, [])
self.assertEqual(len(branches), len(calls))
# Both kinds are present, so neither half of the loop below is vacuous.
self.assertEqual({proto == 'undefined' for _, proto in branches}, {True, False})
# Both kinds are present, so neither half of the loop below is vacuous;
# an empty capture is a branch answering ``cls.__transport__``.
self.assertEqual({proto == '' for _, proto in branches}, {True, False})
self.assertNotIn('undefined', {proto for _, proto in branches})

for condition, proto in branches:
with self.subTest(condition=condition):
claim = 'cls.__transport__ is TransportProtocol.%s' % proto
if proto == 'undefined':
if proto == '':
self.assertNotIn('cls.__transport__', condition)
else:
# NOTE: anchored at the end rather than a bare `assertIn`,
Expand Down
101 changes: 101 additions & 0 deletions tests/const/test_const_apptype_unassigned_2048_unit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""Every in-range port answers with the asking registry's own transport.

GitHub issue #2048. IANA assigned port 8011 on UDP (``bier-echo``) and marked it
Reserved on TCP, and its CSV then dropped 8011 from the unassigned span
``8011``-``8014`` while listing no DCCP or SCTP entry for it, so DCCP and SCTP
answer ``get``'s ``unknown`` fallback, which the owner kept. The spans in
``AppType._missing_`` that name no transport answer with the asking registry's
transport rather than ``TransportProtocol.undefined``, as the fallback already
did. Lookups still mint nothing (#860), and a port outside the range still
raises (#758).

"""
from __future__ import annotations

import unittest

#: Test tier (#1958): pure-Python lookups over the generated AppType registries,
#: which behave the same on every interpreter.
__tier__ = 'LATEST'

#: ``(port, {registry: (svc)})`` for each registry, in TCP, UDP, SCTP, DCCP order.
EXPECTED = (
# IANA's rows on TCP and UDP only; nothing on SCTP or DCCP.
(8011, ('reserved', 'bier-echo', 'unknown', 'unknown')),
(8009, ('nvme-disc', 'reserved', 'unknown', 'unknown')),
# An unassigned span naming no transport.
(8012, ('unassigned', 'unassigned', 'unassigned', 'unassigned')),
# A reserved span naming no transport.
(226, ('reserved', 'reserved', 'reserved', 'reserved')),
# Spans naming TCP and UDP (#760); nothing on SCTP or DCCP.
(6010, ('x11', 'x11', 'unknown', 'unknown')),
# No row and no span on any transport.
(54321, ('unknown', 'unknown', 'unknown', 'unknown')),
)


def _registries() -> 'tuple[type, ...]':
from pcapkit.const.reg.apptype import DCCP, SCTP, TCP, UDP

return TCP, UDP, SCTP, DCCP


def _sizes(registry: 'type') -> 'tuple[int, int, int]':
"""What a minting lookup would grow on ``registry``."""
return (len(registry.__members__), # type: ignore[attr-defined]
len(registry._value2member_map_), # type: ignore[attr-defined]
len(list(registry.__registry__.items()))) # type: ignore[attr-defined]


class AppTypeUnassigned2048Tests(unittest.TestCase):
"""Every in-range port answers with the asking registry's transport."""

def test_each_registry_answers_with_its_own_transport(self) -> None:
from pcapkit.const.reg.apptype import AppType

for port, answers in EXPECTED:
for registry, svc in zip(_registries(), answers):
with self.subTest(registry=registry.__name__, port=port):
member = registry.get(port)
self.assertIsInstance(member, registry)
self.assertEqual(member.svc, svc)
self.assertEqual(member.port, port)
self.assertIs(member.proto, registry.__transport__)
self.assertEqual(AppType.get(port, proto=registry.__transport__), member)

def test_the_issues_own_port(self) -> None:
from pcapkit.const.reg.apptype import DCCP, SCTP, TCP, UDP

self.assertEqual(repr(TCP.get(8011)), '<TCP.reserved: 8011 [tcp]>')
self.assertEqual(repr(UDP.get(8011)), '<UDP.bier-echo: 8011 [udp]>')
self.assertEqual(repr(SCTP.get(8011)), '<SCTP.unknown: 8011 [sctp]>')
self.assertEqual(repr(DCCP.get(8011)), '<DCCP.unknown: 8011 [dccp]>')

def test_lookups_mint_nothing(self) -> None:
"""#860: span and fallback members alike stay unregistered."""
for registry in _registries():
before = _sizes(registry)
for port, _ in EXPECTED:
registry.get(port)
registry.get_all(port)
with self.subTest(registry=registry.__name__):
self.assertEqual(_sizes(registry), before)
self.assertNotIn('unassigned', registry.__members__) # type: ignore[attr-defined]
self.assertNotIn('unknown', registry.__members__) # type: ignore[attr-defined]

def test_out_of_range_still_raises(self) -> None:
"""#758: the rule covers ``0``-``65535`` only."""
from pcapkit.const.reg.apptype import AppType

for registry in _registries():
for port in (-1, 65536):
with self.subTest(registry=registry.__name__, port=port):
with self.assertRaises(ValueError):
registry.get(port)
with self.assertRaises(ValueError):
AppType.get(port, proto=registry.__transport__)


if __name__ == '__main__':
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
"""The AppType generator and its committed base module agree on #2048's rule.

GitHub issue #2048. Every in-range port answers with the asking registry's own
transport. That lives in two places: ``get``'s ``unknown`` fallback for a port
``_missing_`` holds no span for, and the span branches the generator renders
into ``_missing_``. Regenerating :mod:`pcapkit.const.reg.apptype.apptype` needs
IANA's live CSV, so an edit to the template in
:mod:`pcapkit.vendor.reg.apptype.apptype` is made to the committed module by
hand. These render the template offline and check the two agree.

"""
from __future__ import annotations

import importlib.util
import pathlib
import unittest

#: Test tier (#1958): checks the AppType generator's template against the
#: committed module, and imports the crawlers, whose extra is on ``latest`` alone.
__tier__ = 'LATEST'

#: Repository root, i.e. the grandparent of the directory holding this file.
ROOT = pathlib.Path(__file__).resolve().parents[2]

#: Every distribution importing :mod:`pcapkit.vendor` needs -- see
#: :mod:`tests.vendor.test_ipx_packet_unit` for why all three are checked.
VENDOR_DEPS = ('requests', 'bs4', 'html5lib')

#: Whether the crawlers are importable at all.
HAS_VENDOR_DEPS = all(importlib.util.find_spec(name) is not None for name in VENDOR_DEPS)

#: Where ``get`` starts and where the method after it starts, in both texts.
GET_START = " @classmethod\n def get(cls, key: 'int', *,"
GET_END = ' @classmethod\n def get_all('


def _get_method(text: 'str') -> 'str':
"""The source text of ``get`` within a rendering of the base module."""
start = text.index(GET_START)
return text[start:text.index(GET_END, start)]


@unittest.skipUnless(HAS_VENDOR_DEPS, f'vendor extra not installed ({", ".join(VENDOR_DEPS)})')
class AppTypeGetTemplateAgreement2048Tests(unittest.TestCase):
"""The template's ``get`` renders to the committed one."""

def test_rendered_get_matches_committed_get(self) -> None:
from pcapkit.vendor.reg.apptype.apptype import BASE, AppType

rendered = BASE('AppType', AppType.__doc__, AppType.FLAG, '', '',
'pcapkit.vendor.reg.apptype.apptype')
committed = (ROOT / 'pcapkit' / 'const' / 'reg' / 'apptype' / 'apptype.py').read_text(
encoding='utf-8')

self.assertEqual(_get_method(rendered), _get_method(committed))

def test_rendered_get_falls_back_to_unknown_on_the_registrys_transport(self) -> None:
from pcapkit.vendor.reg.apptype.apptype import BASE, AppType

method = _get_method(BASE('AppType', AppType.__doc__, AppType.FLAG, '', '',
'pcapkit.vendor.reg.apptype.apptype'))

self.assertIn("_unregistered_member(key, 'unknown', owner.__transport__)", method)
self.assertNotIn("'unassigned'", method)

def test_a_span_naming_no_transport_answers_with_the_registrys(self) -> None:
"""A span IANA lists for one transport keeps its claim and its transport."""
from pcapkit.vendor.reg.apptype.apptype import AppType

_, miss = AppType.__new__(AppType).records([
'Service Name,Port Number,Transport Protocol,Description,Assignee,Contact,'
'Registration Date,Modification Date,Reference,Service Code,'
'Unauthorized Use Reported,Assignment Notes',
',8012-8014,,Unassigned,,,,,,,,',
'x11,6000-6063,tcp,X Window System,,,,,,,,',
])

self.assertEqual(miss[0], 'if 8012 <= value <= 8014:')
self.assertEqual(miss[2], " return cls._unregistered_member(value, 'unassigned', "
'cls.__transport__)')
self.assertEqual(miss[3], 'if 6000 <= value <= 6063 and cls.__transport__ is '
'TransportProtocol.tcp:')
self.assertEqual(miss[5], " return cls._unregistered_member(value, 'x11', "
'TransportProtocol.tcp)')

def test_committed_spans_naming_no_transport_answer_with_the_registrys(self) -> None:
source = (ROOT / 'pcapkit' / 'const' / 'reg' / 'apptype' / 'apptype.py').read_text(
encoding='utf-8')
method = source[source.index(' def _missing_(cls, value'):]

self.assertNotIn('TransportProtocol.undefined)', method)
self.assertIn("_unregistered_member(value, 'unassigned', cls.__transport__)", method)


if __name__ == '__main__':
unittest.main()
Loading