From 7c3df5e97ad777bac85f7627f9ee621e13937706 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 22:32:29 +0000 Subject: [PATCH] Align the layer sub-package exports with the top-level package Each layer's __init__ carried a subset of what the top-level package re-exports, so "from netprotocols.layer7 import DHCP" failed while "from netprotocols import DHCP" worked -- an asymmetry that only bites people who import by layer, and is invisible from the top. layer3 was missing GRE, IPv4Option, IPv6Option, NDPOption and IGMPv3GroupRecord; layer7 exported only DNS, omitting DHCP, DNSOverTCP and DNSResourceRecord. layer4 was missing TCPOption, which the issue did not mention -- the same defect, found by deriving the expected set rather than working from the reported list. tests/test_exports.py now derives that expectation from each object's __module__, so a protocol added to the top level but forgotten in its layer fails the suite. It also checks the converse (no layer exports a name the top level lacks) and guards its own derivation, so a broken __module__ inspection cannot make the parametrized cases vacuously pass. Closes #80 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QJnVMNGwTRDktC4rkABtgt --- CHANGELOG.md | 14 +++++ src/netprotocols/layer3/__init__.py | 13 +++-- src/netprotocols/layer4/__init__.py | 4 +- src/netprotocols/layer7/__init__.py | 5 +- tests/test_exports.py | 82 +++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 tests/test_exports.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 28405de..238488f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 publish job gated behind it, so the release gate and the pull-request gate are one definition and cannot drift apart. +### Fixed +- **Layer sub-packages now export everything their layer defines.** + `from netprotocols.layer7 import DHCP` failed while + `from netprotocols import DHCP` worked, because each layer's own + `__init__` carried a subset of what the top-level package re-exports. + `layer3` was missing `GRE`, `IPv4Option`, `IPv6Option`, `NDPOption` + and `IGMPv3GroupRecord`; `layer4` was missing `TCPOption`; `layer7` + exported only `DNS`, omitting `DHCP`, `DNSOverTCP` and + `DNSResourceRecord`. Purely additive — no name changed meaning. + ### Development +- `tests/test_exports.py` keeps the layer and top-level export sets in + agreement, deriving the expectation from each object's `__module__` + rather than a hand-written list, so a protocol added to the top level + but forgotten in its layer fails the suite. - Coverage is now enforced, not merely reported: `fail_under = 98` in `[tool.coverage.report]`. The suite covers 99% of 1441 statements — the only misses are `Packet.__repr__` and its `__eq__` diff --git a/src/netprotocols/layer3/__init__.py b/src/netprotocols/layer3/__init__.py index 2364f63..afe4f2c 100644 --- a/src/netprotocols/layer3/__init__.py +++ b/src/netprotocols/layer3/__init__.py @@ -1,21 +1,28 @@ -from netprotocols.layer3.icmp import ICMPv4, ICMPv6 -from netprotocols.layer3.igmp import IGMP -from netprotocols.layer3.ip import IPv4, IPv6 +from netprotocols.layer3.gre import GRE +from netprotocols.layer3.icmp import ICMPv4, ICMPv6, NDPOption +from netprotocols.layer3.igmp import IGMP, IGMPv3GroupRecord +from netprotocols.layer3.ip import IPv4, IPv4Option, IPv6 from netprotocols.layer3.ipv6_ext import ( IPv6DestinationOptions, IPv6Fragment, IPv6HopByHopOptions, + IPv6Option, IPv6Routing, ) __all__ = [ + "GRE", "IGMP", "ICMPv4", "ICMPv6", + "IGMPv3GroupRecord", "IPv4", + "IPv4Option", "IPv6", "IPv6DestinationOptions", "IPv6Fragment", "IPv6HopByHopOptions", + "IPv6Option", "IPv6Routing", + "NDPOption", ] diff --git a/src/netprotocols/layer4/__init__.py b/src/netprotocols/layer4/__init__.py index bd44ff7..95503be 100644 --- a/src/netprotocols/layer4/__init__.py +++ b/src/netprotocols/layer4/__init__.py @@ -1,4 +1,4 @@ -from netprotocols.layer4.tcp import TCP +from netprotocols.layer4.tcp import TCP, TCPOption from netprotocols.layer4.udp import UDP -__all__ = ["TCP", "UDP"] +__all__ = ["TCP", "UDP", "TCPOption"] diff --git a/src/netprotocols/layer7/__init__.py b/src/netprotocols/layer7/__init__.py index 1517d2a..dd97d92 100644 --- a/src/netprotocols/layer7/__init__.py +++ b/src/netprotocols/layer7/__init__.py @@ -1,3 +1,4 @@ -from netprotocols.layer7.dns import DNS +from netprotocols.layer7.dhcp import DHCP +from netprotocols.layer7.dns import DNS, DNSOverTCP, DNSResourceRecord -__all__ = ["DNS"] +__all__ = ["DHCP", "DNS", "DNSOverTCP", "DNSResourceRecord"] diff --git a/tests/test_exports.py b/tests/test_exports.py new file mode 100644 index 0000000..1f27474 --- /dev/null +++ b/tests/test_exports.py @@ -0,0 +1,82 @@ +"""Tests that each layer sub-package exports what the layer defines. + +The top-level package re-exports every public name, so an omission in a +layer's own ``__init__`` is invisible until someone writes +``from netprotocols.layer7 import DHCP`` and it fails while +``from netprotocols import DHCP`` works. Deriving the expectation from +``__module__`` rather than a hand-written list means a protocol added to +the top level but forgotten in its layer fails here. +""" + +import importlib + +import pytest + +import netprotocols + +LAYER_PACKAGES = ["layer2", "layer3", "layer4", "layer7"] + + +def owning_layer(name: str) -> str | None: + """The layer sub-package defining ``name``, if any.""" + module = getattr(getattr(netprotocols, name), "__module__", None) + if module is None: # e.g. __version__, a plain str + return None + parts = module.split(".") + if len(parts) >= 2 and parts[1] in LAYER_PACKAGES: + return parts[1] + return None + + +LAYER_NAMES = sorted( + (layer, name) + for name in netprotocols.__all__ + if (layer := owning_layer(name)) is not None +) + + +class TestLayerExports: + @pytest.mark.parametrize("layer,name", LAYER_NAMES) + def test_name_is_exported_by_its_layer(self, layer: str, name: str) -> None: + """Anything importable from netprotocols is importable from its + own layer sub-package, and is the very same object.""" + package = importlib.import_module(f"netprotocols.{layer}") + assert name in package.__all__, ( + f"netprotocols.{name} is defined in {layer} but missing from " + f"netprotocols/{layer}/__init__.py's __all__" + ) + assert getattr(package, name) is getattr(netprotocols, name), ( + f"netprotocols.{layer}.{name} is a different object from " + f"netprotocols.{name}" + ) + + @pytest.mark.parametrize("layer", LAYER_PACKAGES) + def test_layer_exports_nothing_extra(self, layer: str) -> None: + """A layer must not export a name the top level does not, which + would make it reachable by only one of the two paths.""" + package = importlib.import_module(f"netprotocols.{layer}") + extra = set(package.__all__) - set(netprotocols.__all__) + assert not extra, ( + f"netprotocols/{layer}/__init__.py exports {sorted(extra)}, " + f"absent from the top-level __all__" + ) + + @pytest.mark.parametrize("layer", LAYER_PACKAGES) + def test_layer_all_matches_its_namespace(self, layer: str) -> None: + """Every name in a layer's __all__ actually resolves.""" + package = importlib.import_module(f"netprotocols.{layer}") + missing = [n for n in package.__all__ if not hasattr(package, n)] + assert not missing, ( + f"netprotocols/{layer}/__init__.py lists {missing} in __all__ " + f"but does not import them" + ) + + def test_every_layer_contributes_something(self) -> None: + """Guards the derivation itself: if __module__ inspection broke, + LAYER_NAMES would silently empty and every test above would + vacuously pass.""" + covered = {layer for layer, _ in LAYER_NAMES} + assert covered == set(LAYER_PACKAGES), ( + f"expected every layer to own at least one exported name; " + f"got {sorted(covered)}" + )