fix(corekit): reject a signed= that contradicts a field's fixed sign (#545) - #549
Conversation
…545) Closes #545. All eight `*IntField` subclasses documented a `signed` constructor argument that `__signed__` then discarded -- in both directions, measured on 691f12a: `UInt8Field(signed=True)._signed` was `False` and `Int8Field(signed=False)._signed` was `True`. A caller who asked for a signed field got unsigned parsing with no warning, and the values only look wrong once the high bit is set, which for a length or an identifier may never happen in testing. Deleting the docstring line would have left the trap in place with its advertisement removed, so the argument is rejected instead. No in-tree caller is affected: every `signed=` in the tree goes to the base `NumberField`, where `__signed__` is unset and the argument always worked, and no call site passes it positionally. - numbers.py: `signed` defaults to `None` rather than `False`. That is what lets an explicit contradiction be told apart from the default, and both directions need it -- `Int8Field(signed=False)` is otherwise indistinguishable from `Int8Field()`. A contradiction raises `FieldValueError` naming the class and the sign it fixes; an agreeing or omitted value behaves exactly as before. `FieldValueError` rather than `BoolError`, which means "must *be* a bool", and rather than `FieldError`, which this package raises for a missing or wrong-kind argument (`SchemaField` with no schema); `BitField.__init__` already rejects a bad `namespace` *value* with `FieldValueError`. - numbers.py: the struct template is built from the resolved `self._signed` rather than from the raw argument. Same defect one level down -- a subclass fixing `__signed__` without `__template__` declared itself signed and then unpacked unsigned, giving `>I` and parsing `b'\xff\xff\xff\xff'` as `4294967295` instead of `-1`. Nothing in the tree does this today; the base class allows it, and `__call__` and `pre_process` already used `self._signed`. - numbers.py: all ten docstrings that documented `signed` now say what it does, and the eight fixed classes document the `Raises:`. - tests: `tests/corekit/test_fields_numbers.py`, the first test module for this file, covering both directions across all eight classes, the agreeing and omitted cases, the census by introspection so a ninth subclass cannot escape it, and the template defect through a parse rather than a string compare. Unit tier green: 1048 passed, 8 skipped.
|
✅ GOOD TO MERGE — independently reproduced all four fails-without rows exactly (fully reverted: 5 failed/7 passed; template-line-only reverted: 1 failed/11 passed; the half-fix that rejects only the unsigned direction: 3 failed/9 passed, exit 1 in every case), confirmed |
Detailed review (independent verification, falsify-not-bless)Head sha reviewed: The
|
|
Follow-up on the full-suite number promised above (the first run was contaminated by
Close to but not identical to the PR's claimed "1048 passed, 8 skipped": the totals match |
Closes #545.
The defect
All eight
*IntFieldsubclasses documented asignedconstructor argument that__signed__then discarded — in both directions. Re-measured on this branch'sbase
691f12ab5, in a repo venv withpcapkit.__file__asserted to be the treeunder test:
A caller writing
UInt32Field(signed=True)reasonably believes they asked for asigned field and gets unsigned parsing with no warning. The values only look wrong
once the high bit is set, which for a length or an identifier may never happen in
testing — so the documentation is what invites the mistake, and deleting the
docstring line alone (option 1 in the issue) would leave the trap in place with its
advertisement removed.
The real count, by introspection rather than grep — every
NumberFieldsubclass whose
__signed__is notNone, and every one discards in its owndirection:
__signed__Int8Field,Int16Field,Int32Field,Int64FieldTrue(signed=False)._signed→TrueUInt8Field,UInt16Field,UInt32Field,UInt64FieldFalse(signed=True)._signed→FalseEnumFieldandNumberFielditself leave__signed__unset, so theirsignedargument was always real and stays so.
PortEnumField/OptionEnumFieldin theschema modules subclass
EnumFieldwithout fixing a sign, so their docstrings werealready accurate and are untouched.
The decision: option 2
Rejecting, not documenting away and not honouring.
The grep that decided it.
signed=appears at 12 field-construction sites inthe tree and every one of them targets the base
NumberField, where__signed__is unset and the argument already worked:schema/internet/hopopt.py:692,722,schema/internet/ipv6_opts.py:697,727,schema/internet/hip.py:434,454,456,519,1003,1016,1308,1310,schema/application/httpv2.py:91,schema/transport/tcp.py:744,749. No sitepasses
signedto one of the eight fixed subclasses, by keyword or positionally(checked separately — there are no positional constructions of these classes at
all). So option 2 is a bug fix rather than a breaking change, and option 1 is not
forced.
Option 3 — letting
signed=override__signed__— was rejected: the point ofInt8FieldversusUInt8Fieldis that the sign is fixed by the type, and makingit overridable reintroduces the ambiguity the split exists to remove. It would also
need
__template__to become overridable in lockstep, since the two encode thesame fact.
The exception:
FieldValueErrorBoolErroris wrong — it means "must be a bool", and the argument here is aperfectly good bool whose value contradicts the class.
FieldError(aTypeError) is what this package raises for a missing orwrong-kind constructor argument —
SchemaField.__init__raises it asFieldError('Schema field must have a schema.').FieldValueError(aValueError) is what it raises for a constructorargument whose value is impossible:
BitField.__init__already rejects anover-wide
namespaceentry with it, in the same directory and with the samef'{type(self).__name__}: …'message shape. Right type, impossible value, isValueErrorterritory in Python generally.A second instance of the same shape, fixed in the same pass
__signed__was resolved intoself._signed, but the struct template was builtfrom the raw
signedargument. A subclass that fixes__signed__and leaves__template__unset — which the base class explicitly allows — therefore declareditself signed and then unpacked unsigned. Measured on the base:
so
b'\xff\xff\xff\xff'parsed as4294967295rather than-1. Nothing inpcapkitfixes__signed__without__template__today, so this was latent; theline now reads
self._signed, matching__call__andpre_process, which alreadydid.
Also checked and not the same shape:
__length__, where an explicitlength=wins over the class attribute rather than being discarded, so the argument is
documented truthfully.
grepping the whole package for thex if self.__attr__ is None else self.__attr__pattern finds__signed__andnothing else.
The fix
signeddefaults toNonerather thanFalse. That sentinel is what lets anexplicit contradiction be told apart from the default, and both directions need it —
Int8Field(signed=False)is otherwise indistinguishable fromInt8Field(). Acontradiction raises
FieldValueErrornaming the class and the sign it fixes; anagreeing or omitted value behaves exactly as before. Judged on truth value, so
signed=1contradicts an unsigned field just assigned=Truedoes.Evidence
tests/corekit/test_fields_numbers.pyis new — this file had no test module at all,which is why the defect survived. 12 tests.
Every run below used
PYTHONSAFEPATH=1withPYTHONPATHset to the worktree and apytest plugin that imports
pcapkitinpytest_report_headerand raises unlesspcapkit.__file__is inside that tree, so no measurement here could have come fromthe editable install. Exit codes read from a file, never from a pipeline.
signedfix fully reverted-k unsigned_field_rejectsUInt8Field(signed=True) was accepted)-k signed_field_rejectsInt8Field(signed=False) was accepted)The half-fix row is the point of testing both directions: it passes
test_an_unsigned_field_rejects_a_contradicting_signed_trueand failstest_a_signed_field_rejects_a_contradicting_signed_false. A test covering onedirection would have signed off on it.
Because this adds a rejection that a caller outside the tree could hit, the full
unit tier was run, not just the new module:
isort --check-onlyis clean on both files.mypyandpylinteach report exactlyone finding in
numbers.pyand both are pre-existing on untouched lines — theenum.IntEnum('<unknown>', …)name mismatch inEnumField.post_process, andW1309onraise IntError(f'Field has no length.'). Neither gates CI, which runspytest only.
CI has not run on this branch. The runner has a deep backlog (~27 queued, 1 in
progress) and no PR branch has had a check start in over an hour, so the numbers
above are local.
Left for the owner
FieldError's own docstring,"The argument(s) must be *field* type.", isnarrower than its established use (
SchemaFieldraises it for a missingargument).
pcapkit/utilities/exceptions.pyis not touched here.Int32Field(length=2)builds a field whose_lengthis 2 and whose_templateis
>ifrom__template__, sounpackraisesstruct.error— while__call__recomputes the template from the length and ignores
__template__entirely. Theinverse trap to this one, and a separate issue.
raise IntError(f'Field has no length.')atnumbers.py:81is an f-string withno interpolation, and
IntError("must be integral") is an odd fit for "nolength was given". Left alone as unrelated to *IntField subclasses document a signed argument that __signed__ silently discards #545.