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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Preceded by `1.5.0a1` (2026-09-15), `1.5.0b1` and `1.5.0b2` (both 2026-09-18) an
#### Added

- three extraction engines: `engine='pypcap'` and `engine='pcap_ct'`, two independent distributions of the same `libpcap` interface, and `engine='pypcapfile'` ([#386](https://github.com/JarryShaw/PyPCAPKit/pull/386), [#405](https://github.com/JarryShaw/PyPCAPKit/pull/405)). They buy speed by doing less -- neither `pypcap` nor `pcap_ct` dissects at all, so they offer no reassembly or flow tracing, and `pypcapfile` has no IPv6 decoder. Install only **one** of `pypcap` and `pcap-ct`: both own the top-level `pcap` module, and with both present `pcap-ct` wins the import and the other becomes unselectable. The missing interface constants `PyPCAP`, `PCAP_CT` and `PyPCAPFile` are now exported alongside `DPKT`, `Scapy`, `PyShark` and `PCAPKit` ([#412](https://github.com/JarryShaw/PyPCAPKit/pull/412)). That makes seven built-in engines; 3.11 is the last interpreter on which every one can run, and even there two cannot coexist.
- `EngineBase.unsupported_reason`, a preflight every engine answers and `Extractor.run` consults before anything is imported. An engine that cannot run in the current environment now gives one warning naming the real cause (a Python version, a missing `tshark` or `libpcap`, the wrong `pcap` distribution) and a clean fall back to `pcapkit`'s own parser, rather than an error from inside the third-party package ([#396](https://github.com/JarryShaw/PyPCAPKit/pull/396), [#405](https://github.com/JarryShaw/PyPCAPKit/pull/405)).
- `EngineBase.unsupported_reason`, a preflight every engine answers and `Extractor.run` consults before anything is imported. An engine that cannot run in the current environment now gives one warning naming the real cause (a Python version, a missing `tshark` or `libpcap`, the wrong `pcap` distribution) and a clean fall back to `pcapkit`'s own parser, rather than an error from inside the third-party package ([#396](https://github.com/JarryShaw/PyPCAPKit/pull/396), [#405](https://github.com/JarryShaw/PyPCAPKit/pull/405)). The same holds for an engine whose package is not installed at all ([#1045](https://github.com/JarryShaw/PyPCAPKit/issues/1045)).
- `conflict` on the reassembly data models: absolute, inclusive ranges where two fragments claimed the same span with different bytes, previously lost silently on both the IP ([#482](https://github.com/JarryShaw/PyPCAPKit/pull/482)) and TCP ([#443](https://github.com/JarryShaw/PyPCAPKit/issues/443), [#478](https://github.com/JarryShaw/PyPCAPKit/pull/478)) paths.

#### Changed
Expand All @@ -101,6 +101,7 @@ Preceded by `1.5.0a1` (2026-09-15), `1.5.0b1` and `1.5.0b2` (both 2026-09-18) an
- `register_protocol`'s overwrite warning could claim a protocol was replaced with itself. The guard was correct (`incumbent is not protocol`, an identity check from [#681](https://github.com/JarryShaw/PyPCAPKit/pull/681)), but the message built both operands with a bare `repr()`. A factory defining a same-named closure-local class on every call (as `tests/protocols/test_construction_keyword_check_unit.py`'s `_protocol_class` does) produces two distinct classes sharing one `__module__` and `__qualname__`, so a real overwrite read "overwriting X with X." The message now compares the two reprs and, only when they coincide, appends each object's `id()`. `__module__`/`__qualname__` was rejected as the disambiguator: for the reported shape they are exactly what the coinciding repr already renders. The new `test_register_protocol_disambiguates_classes_sharing_a_repr` fails against the unfixed message ([#710](https://github.com/JarryShaw/PyPCAPKit/issues/710)).
- five more registrars warned on mere key presence rather than an actual overwrite, outside the wording of [#718](https://github.com/JarryShaw/PyPCAPKit/issues/718)'s identity guard (`incumbent is not None and incumbent is not new`, landed by [#726](https://github.com/JarryShaw/PyPCAPKit/pull/726)), whose issue named only code-keyed registrars: `register_engine`, `register_reassembly` and `register_traceflow` on `Extractor`, and `register_dumper` on both `Extractor` and `TraceFlow`. All five now compare the incumbent by identity before warning; both `register_dumper` sites compare only the stored dumper, so re-registering with just a new file extension stays silent too, judged defensible rather than comparing the full `(dumper, ext)` pair. Non-breaking: a correct caller sees strictly fewer warnings and no change to return value or exception. Five new tests pin the silent/warns-anyway split ([#739](https://github.com/JarryShaw/PyPCAPKit/issues/739)).
- **six registrars** now raise `RegistryError` for a non-class argument, where a bare `TypeError` ("issubclass() arg 1 must be a class") used to escape -- from the guard itself for the two `register_dumper` methods, and from inside `abc` for the other four. They are `register_dumper`, `register_engine`, `register_reassembly` and `register_traceflow` on `Extractor`, `TraceFlow.register_dumper` (reached through `register_traceflow_dumper`), and `pcapkit.foundation.registry.protocols.register_protocol`. Each guard gains an explicit `isinstance(x, type)` test ahead of its `issubclass`; in the five that accept a `ModuleDescriptor` it runs after the descriptor is unwrapped, so a descriptor naming a non-class attribute is rejected too, while `register_protocol` takes no descriptor. No guard's target class changes, and a wrong class still raises `RegistryError` as before. `RegistryError` subclasses `TypeError`, so `except TypeError` still catches it; a caller matching the exact type, or the old message, does not. **Not every site is covered**: six of the thirteen bare `issubclass` guards in the package are fixed here, and the same guard remains in the `register` classmethods of `ProtocolBase`, `Frame`, `PCAPNG`, `SCTP`, `Link`, `Internet` and `Transport`, all of which still leak `TypeError` for a non-class. `Transport.register` is reachable despite its `UnsupportedCall`, which is gated on `cls is Transport` and so fires only for the abstract class -- the guard below it leaks through `TCP.register` and `UDP.register`, which is the only way it is ever called. [#1026](https://github.com/JarryShaw/PyPCAPKit/issues/1026) tracks all seven ([#1021](https://github.com/JarryShaw/PyPCAPKit/issues/1021)).
- a missing extraction engine package now gives one `EngineWarning` rather than two ([#1045](https://github.com/JarryShaw/PyPCAPKit/issues/1045), [#1046](https://github.com/JarryShaw/PyPCAPKit/pull/1046)). `Extractor.import_test` warned that the package was absent, then `Extractor.run` warned again when it fell back to the default engine; `run` now stays silent there. The surviving message is the one `run` already emitted, `engine <name> (<module>) is not installed; using default engine instead` (the module name is in backticks in the real message, which this changelog's Markdown generator cannot show literally), so `import_test` now says the same. Only code that matched `import_test`'s old wording, `extraction engine '<name>' not available; using default engine instead`, or that expects two warnings, breaks.

### pcapkit.protocols

Expand Down
13 changes: 12 additions & 1 deletion docs/source/changelog/1.5.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -764,7 +764,8 @@ Added
run in the current environment now gives one warning naming the real cause (a
Python version, a missing ``tshark`` or ``libpcap``, the wrong ``pcap``
distribution) and a clean fall back to ``pcapkit``'s own parser, rather than an
error from inside the third-party package (:pr:`396`, :pr:`405`).
error from inside the third-party package (:pr:`396`, :pr:`405`). The same holds
for an engine whose package is not installed at all (:issue:`1045`).
* ``conflict`` on the reassembly data models: absolute, inclusive ranges where
two fragments claimed the same span with different bytes, previously lost
silently on both the IP (:pr:`482`) and TCP (:issue:`443`, :pr:`478`) paths.
Expand Down Expand Up @@ -1029,6 +1030,16 @@ Fixed
is gated on ``cls is Transport`` and so fires only for the abstract class -- the guard
below it leaks through ``TCP.register`` and ``UDP.register``, which is the only way it
is ever called. :issue:`1026` tracks all seven (:issue:`1021`).
* a missing extraction engine package now gives one ``EngineWarning`` rather than two
(:issue:`1045`, :pr:`1046`). ``Extractor.import_test`` warned that the package was
absent, then ``Extractor.run`` warned again when it fell back to the default engine;
``run`` now stays silent there. The surviving message is the one ``run`` already
emitted, ``engine <name> (<module>) is not installed; using default engine instead``
(the module name is in backticks in the real message, which this changelog's
Markdown generator cannot show literally), so ``import_test`` now says the same.
Only code that matched ``import_test``'s old wording,
``extraction engine '<name>' not available; using default engine instead``, or
that expects two warnings, breaks.

pcapkit.protocols
-----------------
Expand Down
2 changes: 1 addition & 1 deletion docs/source/contributing/conventions/process.rst
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ commands down instead of a figure that will be stale by the next merge:
The grouping scheme was settled on :issue:`918`: **a section per top-level module, with**
``Added``/``Changed``/``Fixed`` **nested inside each** -- module granularity, not
per-file and not per-subpackage. The file carries **9** module-level sections holding
162 entries, and no entry carries an inline kind label::
163 entries, and no entry carries an inline kind label::

$ grep -cE '^\* \*\*(Added|Changed|Fixed)\*\*' docs/source/changelog/1.5.0.rst
0
Expand Down
7 changes: 4 additions & 3 deletions pcapkit/foundation/extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -690,8 +690,8 @@ def run(self) -> 'None': # pylint: disable=inconsistent-return-statements
self.record_frames()
return
else:
warn(f'engine {eng.name} (`{eng.module}`) is not installed; '
'using default engine instead', EngineWarning, stacklevel=stacklevel())
# ``import_test`` has already warned that the package is absent;
# warning again here would report one problem twice.
self._exnam = 'default' # using default/pcapkit engine

if self._exnam not in ('default', 'pcapkit'):
Expand Down Expand Up @@ -734,7 +734,8 @@ def import_test(engine: 'str', *, name: 'Optional[str]' = None) -> 'Optional[Mod
except ImportError:
module = None
logger.debug('engine module %r is not importable', engine)
warn(f"extraction engine '{name or engine}' not available; "
label = f'{name} (`{engine}`)' if name else engine
warn(f'engine {label} is not installed; '
'using default engine instead', EngineWarning, stacklevel=stacklevel())
return module

Expand Down
51 changes: 51 additions & 0 deletions tests/foundation/test_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,57 @@ def test_run_selects_registered_default_and_error_engines(self) -> None:
with mock.patch('pcapkit.foundation.extraction.PCAPNG_Engine', types.SimpleNamespace(MAGIC_NUMBER=(b'ng!!',))):
bad.run()

def _run_fallback(self, engine: 'type') -> 'tuple[list[str], typing.Any, typing.Any]':
"""Run an extractor requesting ``engine``; return its EngineWarnings and state."""
from pcapkit.utilities.warnings import EngineWarning

extractor = self._bare_extractor()
extractor._exnam = 'fake'
extractor.__engine__ = {'fake': engine}
extractor._magic = b'pcap'
extractor.record_frames = mock.Mock()
fake_pcap = type('FakePCAP', (FakeEngine,), {'MAGIC_NUMBER': (b'pcap',)})
with mock.patch('pcapkit.foundation.extraction.PCAP_Engine', fake_pcap):
with mock.patch('pcapkit.foundation.extraction.PCAPNG_Engine',
types.SimpleNamespace(MAGIC_NUMBER=(b'ng!!',))):
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
extractor.run()
messages = [str(w.message) for w in caught if issubclass(w.category, EngineWarning)]
return messages, extractor, fake_pcap

def test_missing_engine_package_warns_once_and_falls_back(self) -> None:
# The module does not exist, so the result is independent of what CI installs.
absent = type('AbsentEngine', (FakeEngine,), {
'name': 'Absent', 'module': 'missing_engine_module_for_unit_tests'})
messages, extractor, fake_pcap = self._run_fallback(absent)
self.assertEqual(messages, ['engine Absent (`missing_engine_module_for_unit_tests`) '
'is not installed; using default engine instead'])
self.assertEqual(extractor._exnam, 'default')
self.assertIsInstance(extractor._exeng, fake_pcap)

def test_import_test_without_display_name_names_module_once(self) -> None:
from pcapkit.foundation.extraction import Extractor
from pcapkit.utilities.warnings import EngineWarning

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
self.assertIsNone(Extractor.import_test('definitely_missing_mod'))
messages = [str(w.message) for w in caught if issubclass(w.category, EngineWarning)]
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0].count('definitely_missing_mod'), 1)
self.assertNotIn('(`definitely_missing_mod`)', messages[0])

def test_unsupported_engine_warns_once_and_falls_back(self) -> None:
blocked = type('BlockedEngine', (FakeEngine,), {
'name': 'Blocked',
'unsupported_reason': classmethod(lambda cls: 'unit test says no')})
messages, extractor, fake_pcap = self._run_fallback(blocked)
self.assertEqual(messages, ['engine Blocked is not supported on this interpreter '
'(unit test says no); using default engine instead'])
self.assertEqual(extractor._exnam, 'default')
self.assertIsInstance(extractor._exeng, fake_pcap)

def test_record_header_record_frames_iteration_call_and_cleanup(self) -> None:
from pcapkit.foundation.extraction import Extractor
from pcapkit.utilities.exceptions import CallableError, FormatError, IterableError
Expand Down
Loading