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
5 changes: 5 additions & 0 deletions docs/source/pcapkit/const/http.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ which is automatically generated from :class:`pcapkit.vendor.http.method.Method`
:exc:`AttributeError` from ``key.upper()``, and ``get(b'GET')`` raised
:exc:`TypeError`.

:meth:`Method._missing_ <pcapkit.const.http.method.Method._missing_>` also checks a
folded ``value`` against member values, not only names, so a hyphenated method such
as ``'baseline-control'`` resolves to :attr:`Method.BASELINE_CONTROL`, with the
registered ``safe`` and ``idempotent``, in any case (:issue:`2284`).

.. autoclass:: pcapkit.const.http.method.Method
:members:
:undoc-members:
Expand Down
6 changes: 5 additions & 1 deletion pcapkit/const/http/method.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,9 @@ def _missing_(cls, value: 'str') -> 'Method':

Args:
value: Value to get enum item. Matched case-insensitively against
the canonical upper-case member names -- deliberately unlike
the canonical upper-case member names, then against member
values, so ``'baseline-control'`` resolves to
``BASELINE_CONTROL`` (:issue:`2284`) -- deliberately unlike
:meth:`get`, which is case-**sensitive** per
:rfc:`9110#section-9.1`. The split was ruled deliberate on
GitHub issue :issue:`896`; GitHub issue :issue:`908` is the pointer between
Expand All @@ -329,4 +331,6 @@ def _missing_(cls, value: 'str') -> 'Method':
name = value.upper()
if name in cls._member_map_:
return cls._member_map_[name]
if name in cls._value2member_map_:
return cls._value2member_map_[name]
return cls._unregistered_member(value, name)
6 changes: 5 additions & 1 deletion pcapkit/vendor/http/method.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,9 @@ def _missing_(cls, value: 'str') -> '{NAME}':

Args:
value: Value to get enum item. Matched case-insensitively against
the canonical upper-case member names -- deliberately unlike
the canonical upper-case member names, then against member
values, so ``'baseline-control'`` resolves to
``BASELINE_CONTROL`` (:issue:`2284`) -- deliberately unlike
:meth:`get`, which is case-**sensitive** per
:rfc:`9110#section-9.1`. The split was ruled deliberate on
GitHub issue :issue:`896`; GitHub issue :issue:`908` is the pointer between
Expand All @@ -234,6 +236,8 @@ def _missing_(cls, value: 'str') -> '{NAME}':
name = value.upper()
if name in cls._member_map_:
return cls._member_map_[name]
if name in cls._value2member_map_:
return cls._value2member_map_[name]
return cls._unregistered_member(value, name)
'''.strip() # type: Callable[[str, str, str, str], str]

Expand Down
102 changes: 102 additions & 0 deletions tests/const/test_const_method_missing_value_2284_unit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
"""``Method._missing_`` must find the hyphenated methods by value.

GitHub issue #2284. The constructor resolves case-insensitively (#896): it
upper-cases the value and looks it up. It checked member *names* only, so the
two RFC 3253 methods whose name differs from their value (``BASELINE_CONTROL``
/ ``'BASELINE-CONTROL'``, ``VERSION_CONTROL`` / ``'VERSION-CONTROL'``) missed,
and ``Method('baseline-control')`` minted an unregistered member with
``idempotent=False`` instead of returning the registered one. #908 fixed the
same gap in ``Method.get``; that override stays case-sensitive and is repinned
here. Strings that are no method still mint an unregistered member (#860).

"""
from __future__ import annotations

import inspect
import unittest

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

#: Spellings of the two hyphenated methods, keyed by member name.
SPELLINGS = {
'BASELINE_CONTROL': ('baseline-control', 'Baseline-Control', 'BASELINE-CONTROL'),
'VERSION_CONTROL': ('version-control', 'Version-Control', 'VERSION-CONTROL'),
}


class HyphenatedLookupTests(unittest.TestCase):
"""The constructor returns the registered member in any casing."""

def test_hyphenated_methods_resolve_in_any_case(self) -> None:
"""Without the fix, the lower- and mixed-case spellings mint a
member named e.g. ``BASELINE-CONTROL`` with ``idempotent=False``."""
from pcapkit.const.http.method import Method

for name, spellings in SPELLINGS.items():
member = Method[name]
for value in spellings:
with self.subTest(value=value):
probed = Method(value)
self.assertIs(probed, member)
self.assertTrue(probed.idempotent)
self.assertFalse(probed.safe)

def test_name_lookup_still_folds_case(self) -> None:
"""#896: the constructor's name lookup stays case-insensitive."""
from pcapkit.const.http.method import Method

self.assertIs(Method('get'), Method.GET)
self.assertIs(Method('Get'), Method.GET)

def test_get_stays_case_sensitive(self) -> None:
"""#908/#896: ``get`` does not fold case, so a lower-case hyphenated
spelling is still a distinct, unregistered method there."""
from pcapkit.const.http.method import Method

probed = Method.get('baseline-control')
self.assertIsNot(probed, Method.BASELINE_CONTROL)
self.assertEqual(probed.value, 'baseline-control')


class UnregisteredMemberTests(unittest.TestCase):
"""A string matching no name or value still mints (#860)."""

def test_non_method_strings_mint_an_unregistered_member(self) -> None:
"""The minted member keeps the caller's casing as its value, carries
the upper-cased name, and is not added to either lookup table."""
from pcapkit.const.http.method import Method

before = (len(Method._member_map_), len(Method._value2member_map_))
for value in ('frob', 'Frob-Control', 'baseline control'):
with self.subTest(value=value):
probed = Method(value)
self.assertNotIn(probed, list(Method))
self.assertEqual(probed.value, value)
self.assertEqual(probed.name, value.upper())
self.assertFalse(probed.safe)
self.assertFalse(probed.idempotent)
self.assertEqual(
(len(Method._member_map_), len(Method._value2member_map_)), before)


class VendorTemplateParityTests(unittest.TestCase):
"""The fix lives in the crawler template, so a regeneration keeps it."""

def test_the_crawler_template_carries_the_same_missing(self) -> None:
"""The generated ``_missing_`` appears verbatim in the rendered
template, and both consult ``_value2member_map_``."""
from pcapkit.const.http.method import Method
from pcapkit.vendor.http.method import LINE

rendered = LINE('Method', 'HTTP Method', '<ENUM>', 'pcapkit.vendor.http.method')
source = inspect.getsource(Method._missing_.__func__) # type: ignore[attr-defined]

self.assertIn('cls._value2member_map_[name]', source)
self.assertIn(source.rstrip('\n'), rendered)


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