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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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__`
Expand Down
13 changes: 10 additions & 3 deletions src/netprotocols/layer3/__init__.py
Original file line numberDiff line numberDiff line change
@@ -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",
]
4 changes: 2 additions & 2 deletions src/netprotocols/layer4/__init__.py
Original file line numberDiff line numberDiff line change
@@ -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"]
5 changes: 3 additions & 2 deletions src/netprotocols/layer7/__init__.py
Original file line numberDiff line numberDiff line change
@@ -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"]
82 changes: 82 additions & 0 deletions tests/test_exports.py
Original file line numberDiff line numberDiff line change
@@ -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)}"
)
Loading