Repository navigation
fix(fields): fall through to the bounded pseudo-member instead of minting (#575) - #771
Conversation
|
Decision needed: where to fix the plist escaping this PR exposes. Also adding CI: 4 failures, all one test. Root cause: the pseudo-member renders as Four ways to resolve it, and this is not mine to pick:
I would take (1): it fixes #772's symptom where it actually matters, leaves the spelling alone, and does not wait on an upstream release. But it widens this PR into On
What is confirmed good: |
|
go with your recommendation (option 1) and file an issue to DictDumper. |
| top of it. | ||
|
|
||
| """ | ||
| unknown = enum.IntEnum('<unknown>', { |
There was a problem hiding this comment.
should we actually raise if it's an illegal value since the self._namespace enum rejected it? instead of creating a pseudo value.
There was a problem hiding this comment.
Good question, and it splits into two cases that this PR currently conflates — you are right about one of them.
For an in-range but unassigned value, the pseudo-member is correct and raising would be a regression. The namespace did not reject 54321 as illegal; it has no name for it. Port 54321 is perfectly legal wire data — IANA simply assigns nothing individually in 49152-65535. EnumField.post_process's own docstring (written for #701) spells out what raising cost: the bare ValueError is aenum's, not one of pcapkit.utilities.exceptions, so a caller cannot tell it from a bug of its own, and it is not an EOFError, so Extractor.record_frames does not catch it — one unassigned code cost the whole extraction. It also made the unknown readers the formats require unreachable: PCAP-NG's UnknownBlock and the unassigned option readers of IPv4, TCP, HOPOPT, MH and HIP, because the lookup failed frames before the dispatch that would have selected them. PCAP-NG repeats a block's length at both ends precisely so an unrecognised block can be skipped; that skip is what the fallback restores.
But for a genuinely illegal value it should raise, and right now it does not. Measured on this branch:
54321 (in-range, unnamed) -> <<unknown>.<unassigned>: 54321> intended
80 (assigned) -> <TCP.http: 80 [tcp|udp|sctp]> correct
-1 (ILLEGAL) -> <<unknown>.<unassigned>: -1> *** ABSORBED ***
70000 (ILLEGAL) -> <<unknown>.<unassigned>: 70000> *** ABSORBED ***
#764 made AppType.get raise for anything outside 0-65535 — a breaking change, merged as 57b2c1761. This PR silently undoes it for these four fields. The type-based rule ("absorb any bare ValueError") is too coarse: it cannot tell aenum's "no member has this value" from #764's deliberate range rejection, because both are bare ValueErrors.
The PR's own reasoning was that the range guard is unreachable from parsing since the fields are PortEnumField(length=2, …), so 0-65535 is guaranteed. That is true of the parse path, but post_process is reachable directly, and more to the point it means the absorption buys nothing while quietly reverting a fix that landed deliberately.
So: keep the pseudo-member for unassigned-but-legal, and let an out-of-range rejection propagate. Routing that as a required change.
There was a problem hiding this comment.
I think for in-range unnamed values, we should still return the self._namespace enum tho, rather than creating a new one. so that means we should extend_enum on the self._namepace on that case, which I was expecting the enum itself to handle it.
There was a problem hiding this comment.
Measured this before answering, and there is a third option that gives you what you want without the extend_enum — which matters, because extend_enum is precisely #575's cost.
str.__new__ on the namespace class produces a genuine member of it that is never registered:
obj = str.__new__(TCP, 'unassigned')
obj._name_ = 'unassigned'; obj._value_ = 'unassigned'
obj.port = 54321; obj.svc = 'unknown'; obj.proto = TransportProtocol.tcprepr : <TCP.unknown: 54321 [tcp]>
isinstance TCP : True isinstance AppType : True
.port/.svc/.proto: 54321 / unknown / tcp
registry grew : 6147 -> 6147 members, 6147 -> 6147 values
So it is a self._namespace enum — isinstance(x, TCP) holds, and it reprs identically to a declared member — while the registry does not grow. That satisfies your requirement and #575's at the same time, where extend_enum can only satisfy yours: minting is what costs 111 calls and ~13.5% of extraction self time on http.pcap, which is the whole of #575.
Note TCP is a StrEnum, not int-based — MRO is TCP → AppType → StrEnum → str → ReprEnum — so it is str.__new__, not int.__new__; the latter raises TypeError: TCP is not a subtype of int.
Three caveats I have not resolved, and they decide whether this is actually better than the <unknown> pseudo-enum now in the PR:
TCP(54321)still raises — the instance is absent from_value2member_map_, so it is not recoverable by value lookup. Same as the current pseudo-member, but worth being explicit that "returns aTCP" does not mean "findable as aTCP".AppTypedefines__eq__/__hash__on.port, so two unregistered instances for the same port would compare equal without being identical. Whether anything depends onisneeds checking.- Pickling and
copymay not survive an unregistered member. If any dump path pickles these, that breaks.
Also, on "I was expecting the enum itself to handle it" — that would put it in AppType._missing_, which is where aenum would naturally hook. I steered the worker away from _missing_ on your earlier ruling that get() is the public API and should keep minting for direct callers. If you would rather the enum own this, say so and I will move it there instead of into the field classes.
Which do you want: this unregistered namespace member, or the <unknown> pseudo-enum the PR currently returns? The out-of-range fix is unaffected either way and is already routed.
There was a problem hiding this comment.
this is actually quite a nice workaround and i think we should even apply to all other Enum's legit but unbounded values - so that we dont create registered enums out of unrecognised/unregistered values, unless user/caller explicitly created them.
There was a problem hiding this comment.
Ruling recorded, in your words: "this is actually quite a nice workaround and i think we should even apply to all other Enum's legit but unbounded values - so that we dont create registered enums out of unrecognised/unregistered values, unless user/caller explicitly created them."
Taking the unregistered namespace member for this PR. But measuring the general rule's reach turned up something that corrects a claim I made earlier in this thread: the declared-range _missing_ branches mint per value as well.
TCP.get(49100) -> <TCP.unassigned: 49100 [undefined]> members 6147 -> 6148
TCP.get(49101) -> <TCP.unassigned: 49101 [undefined]> members 6148 -> 6149
LinkType(60000) -> <LinkType.Unassigned_60000: 60000> members 220 -> 221
I had described those as "the bounded declared-range mechanism — the registries doing their job", against the unbounded get() fall-through. Wrong: they are bounded in which ranges they cover, not in how many members they create. A capture touching 200 distinct ports inside one reserved span grows the registry by 200.
So your principle reaches both paths, and the scope is:
extend_enum inside _missing_ : 1056 calls across 113 files
extend_enum inside get() : 113 calls across 112 files
That is 1,169 sites across 113 registries — far beyond this PR. So I am splitting it: #771 keeps its four EnumField subclasses, which is where the measured 111 calls and ~13.5% of extraction self time live and is what closes #575, and the general rule gets its own issue with this measurement in it. Say if you would rather #771 carry the whole thing; I would not, because a 113-registry change with a breaking label and no incremental verification is not reviewable.
Two things your wording settles that were open:
- "unless user/caller explicitly created them" — so a direct
AppType.get(54321)may still register, since that is an explicit caller action. That matches your earlier ruling thatget()is the public API. The parse path never asked for a name, so it gets the unregistered member. - It does not settle whether
AppType._missing_should own this rather than the field classes. For fix(fields): fall through to the bounded pseudo-member instead of minting (#575) #771 I am keeping it in the field classes, per your earlier ruling. The general issue is where the_missing_question belongs, since that is the path with 1,056 sites.
The three caveats I raised still need answering there rather than here: TCP(54321) stays unfindable by value lookup, __eq__/__hash__ key on .port so two such instances compare equal without being identical, and pickling an unregistered member may not survive.
There was a problem hiding this comment.
for .get(), it's not an explicit call. I say we can probably add something like a .register(...) call that takes code, name, and any other required values to generate a proper enum as an explicit call.
for _missing_, it is still scoped to those ranged bounded but not expanded branches, like for ports 100-200, they are all reserverd_nnn branch.
this ruling must apply to all enums under pcapkit.const, not just the AppType classes.
There was a problem hiding this comment.
Recorded, in your words: "for .get(), it's not an explicit call. I say we can probably add something like a .register(...) call that takes code, name, and any other required values to generate a proper enum as an explicit call. for _missing_, it is still scoped to those ranged bounded but not expanded branches, like for ports 100-200, they are all reserverd_nnn branch. this ruling must apply to all enums under pcapkit.const, not just the AppType classes."
That corrects me — I said a direct AppType.get(54321) counted as an explicit caller action and could keep registering. It does not, and the consequence is that registration becomes opt-in through a new API rather than a side effect of lookup. Carried into #775 with the two measurements below.
#771 is unaffected and stays as it is. It only touches the four EnumField subclasses' post_process, never get() or _missing_, so its scope does not move. CI is green at 57c82794e and the switch to the unregistered namespace member is with the worker.
Two things measured that shape the .register() design, neither resolved:
-
.register()cannot have one signature. Member shapes genuinely differ —AppType.__new__(cls, value, name='<null>', proto=TransportProtocol.undefined)carries port/svc/proto, whileLinkType(IntEnum)is a plainIntEnumwith no custom__new__at all. So "code, name, and any other required values" is per-registry. Either.register()lives on each class with its own signature, or it takes**kwargsand each registry validates — the second keeps one call site but moves the error from signature to runtime. Worth deciding before anyone writes it. Nothing namedregisterexists underpcapkit/const/today. -
Three existing tests depend on minting through lookup and will need rewriting to
.register():
tests/const/test_const_apptype_split_unit.py:161 addCleanup(_purge_member, TCP, 'PORT_59001_tcp', 59001)
tests/const/test_const_apptype_split_unit.py:178 addCleanup(_purge_member, UDP, 'unit_test_80', 80)
tests/const/test_const_apptype_split_unit.py:272 addCleanup(_purge_member, TCP, 'PORT_51_tcp', 51)
Your _missing_ scoping is exactly what I measured — those branches mint one member per distinct value inside a declared range, so a capture touching 200 ports in a reserved_nnn span grows the registry by 200. Full scope stands at 1,169 sites across 113 registries (1,056 in _missing_, 113 in get()).
There was a problem hiding this comment.
.register(...)lives per register with its own signature.- update the tests then.
39dd8ac to
57c8279
Compare
731d8b2 to
42efb07
Compare
|
Cross-review at 1. A docstring sentence that this PR's own test contradicts 30 lines away. 2. Dumped key order flips for unassigned members only. The three port sites pass 3. Two new branches are unexecuted by any test. Correcting the review on its fourth point. It reported that the body does not disclose the
So a Two prose gaps. An unclaimed improvement: PCAP-NG option namespace cross-contamination is gone — on Verified green, independently derived: Routed to a worker. Label moves to |
#575) - TCP/UDP/SCTP's PortEnumField and PCAP-NG's OptionEnumField called their registry's .get() directly on every parsed value, which mints a fresh member via aenum.extend_enum for any value no row or documented span covers. On http.pcap that is 111 calls and ~14% of extraction self time, entirely from unassigned ephemeral ports. - All four now peek at what .get() would consult -- the registry's own rows, then AppType's declared IANA spans via _missing_, or OptionType's per-namespace membership -- and only call .get() once one already holds. A genuine miss gets EnumField._unregistered_member: an instance built by calling the registry's own storage base's __new__ (str or int, picked per namespace) directly, skipping the registry's own __new__ and the registration inside it, so isinstance holds and nothing is ever added to any lookup table. Per the owner's ruling, tracked more broadly as #775 and applied here only to these four call sites. - The three port fields check the value against the field's own declared byte width (self.length) before consulting the registry at all, so a port outside that width still raises #764's deliberate rejection instead of being absorbed alongside a genuine in-range miss. A pcapkit.utilities.exceptions rejection still propagates unchanged. AppType.get() and every _missing_ are unmodified, so direct get() callers still mint. - The extra attributes are passed in the order the registry's own __new__ sets them -- svc, port, proto for AppType and opt_name, opt_value for OptionType -- because make_dumper renders a member's addon keys straight out of its __dict__ in insertion order. Any other order dumps an unassigned value's keys the opposite way round from every declared one's, in all four formats, while leaving every value correct: 1117 rendered port blocks of each kind on http.pcap alone. - Enum.__reduce_ex__ reduces a member to (cls, (value,)), the one lookup an unregistered member is absent from, so _unregistered_member installs a __reduce_ex__ of its own that rebuilds an equivalent unregistered member. Without it, removing the mint would have taken away a pickle round-trip that worked before -- and taken it away on read-back, since dumps succeeds either way. copy/deepcopy reduce through it too on 3.10, where Enum.__copy__/__deepcopy__ do not exist. - dictdumper.plist.PLIST (which 'xml' also maps to) writes <string>/<key> content with no entity escaping at all (JarryShaw/DictDumper#125, tracked as #772), so the unregistered member's rendering broke plist/xml reports. make_dumper's object_hook now escapes &, < and > for PLIST-rooted output only, leaving json/tree/text untouched. extend_enum calls on http.pcap: 111 (13.6-14.1%) -> 0. many_interfaces.pcapng: 15 -> 2, both from the untouched _missing_ mechanism. Wall-clock extract(nofile=True), 7 fresh processes per tree at host load 1.29 -> 1.27: http.pcap 0.952 -> 0.679 s, many_interfaces.pcapng 0.130 -> 0.103 s. New unit tests cover no-mint, isinstance, out-of-width, equality-without-identity, value-lookup still raising, in-library-rejection, round-trip, escaping, addon key order, _unregistered_member's int and TypeError branches, and the pickle/copy round-trip; each fails without its fix. coverage run -m pytest and python -m unittest agree. Fixes #575.
42efb07 to
f42e93d
Compare
|
Round-two cross-review at The mechanism attack found nothing. Reproduced independently: The 14-captures arbitration: the worker is right and round one undercounted. Fresh 23-capture sets on both trees, all four formats, 92 files each: exactly 14 captures / 56 files differ, and normalising #772's entity escaping plus the minted-name rename leaves 0 residual differences across all 14. The reason round one said 13 is concrete — there are two mint-name shapes, and its regex covered only the first: Verified green. Coverage under Three non-blocking notes. The One real gap, tracked rather than blocking: #780. |
- common.py: `object_hook` is never handed a mapping key -- dictdumper's
`_append_dict` interpolates it into `'<key>{item}</key>'` and calls
`_encode_value` on the value only -- so both branches that build a
mapping now escape their own keys through one `escape_key` helper: a
MultiDict, where #771 escaped an enum-derived key inline and nothing
else, and a plain dict, where nothing was escaped at all.
- A non-str key is rendered with `format`, i.e. the writer's own
interpolation, so the `<key>` text is unchanged apart from the
escaping. examples/captures/test.pcapng is why that case is handled at
all: its decryption secrets block keys the TLS key log entries by a raw
bytes client random whose repr carries `&`, `<` and `>`.
- json, tree and text are untouched -- `escape_key` is a no-op for them
and a plain dict is not even rebuilt, so the writer still gets the
caller's own mapping with the caller's own key objects in it.
- tests/dumpkit/test_plist_escaping_regression.py is new, fixture-tier
because test.pcapng is generated rather than committed. It pins that
the plist and xml reports parse, that json/tree/text keep the key
verbatim, and that nothing is escaped twice.
Fixes #772. The upstream half is JarryShaw/DictDumper#125, where
`_append_dict` should call `_encode_value` on a key; this does not wait
on it.
Measured on fe80b85 across 6 captures x 5 formats: `ET.parse` of
test.pcapng's plist failed at line 1517 of 1958 before and parses after;
exactly 2 of the 30 reports changed -- that capture's plist and xml,
which are one writer under two names -- and by exactly one line, leaving
the other 28 byte-identical. pcapkit/dumpkit/common.py stays at 100%
coverage over tests/dumpkit/, 84 -> 88 statements and 38 -> 40 branches,
no new misses. make pylint unchanged (6 pre-existing messages), make mypy
clean.
make pylint,make mypy,make isort)make testpasses, and a test case covers the changeWhat is the purpose of your pull request?
fix— corrects a defectfeatperfrefactortestdocscichoreDescription
TCP/UDP/SCTP's
PortEnumFieldand PCAP-NG'sOptionEnumFieldcalled.get()on every parsed value, minting a member viaaenum.extend_enumfor anything no row or documented span covers — 111 calls onhttp.pcap, all ephemeral ports. All four now peek at what.get()would consult and call it only once the value is resolvable; a genuine miss getsEnumField._unregistered_member, a real member of the same registry built through its storage base's__new__, soisinstanceholds and no lookup table grows (the owner's ruling; the broader principle is #775).Riding along: the port fields check the value against the field's own byte width first, so #764's out-of-range rejection still raises instead of being absorbed;
make_dumperescapes&/</>for PLIST-rooted output, whichdictdumpernever did (JarryShaw/DictDumper#125, tracked as #772); and PCAP-NG option namespaces stop cross-contaminating — onmain, once a code was minted underopt,OptionType.getmergedoptover the requested namespace and 6 of the 7 all answeredopt_unknownfor it, where each now gets its own<ns>_unknown.Measured:
extend_enum111 → 0 onhttp.pcapand 15 → 2 onmany_interfaces.pcapng(both from the untouched_missing_);extract(nofile=True)over 7 fresh processes per tree at host load 1.29 → 1.27, 0.952 → 0.679 s and 0.130 → 0.103 s respectively.#575 asked for byte-identical output across the sample captures, as #420 and #427 did, and this deliberately misses that bar: 14 of 23 captures change in all four formats, entirely from the new
<unassigned>rendering plus #772's PLIST escaping. Normalise those two and all 14 are byte-identical with no residual difference — that rendering change is the fix, hencebreaking.A
pickleround-trip of a resolved unassigned member worked before this change (the member was minted, so registered) and would have broken after it, on read-back only sincedumpssucceeds either way._unregistered_membernow installs a__reduce_ex__rebuilding an equivalent unregistered member; verified on protocols 0–5, both picklers, andcopy/deepcopyon 3.11+ and withEnum.__copy__/__deepcopy__deleted to emulate 3.10.Unit tests cover the no-mint, isinstance, out-of-width, equality-without-identity, value-lookup-still-raises, in-library-rejection, round-trip, escaping, addon-key-order,
int/TypeError-branch and pickle/copy paths — each shown to fail without its fix.Fixes #575.