Skip to content

Commit 034cd22

Browse files
committed
fix: type Signal dispatch contract with Protocol
Signal._handlers was typed list[Callable[..., None]] and fire accepted arbitrary **kwargs, so a typo at a fire site or a missing parameter at a handler only blew up the moment a real service event dispatched - hours into a run on a quiet network. mypy could not catch dispatch mismatches when the contract shifted. Lock the contract down: define a ServiceStateChangeHandler Protocol describing the (zeroconf, service_type, name, state_change) keyword signature, type Signal._handlers as a list of that Protocol, and make Signal.fire keyword-only with the four named parameters. register_handler / unregister_handler still accept Callable[..., None] for back-compat and cast at the boundary.
1 parent 44433dd commit 034cd22

3 files changed

Lines changed: 108 additions & 8 deletions

File tree

src/zeroconf/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from ._services import ( # noqa # import needed for backwards compat
5555
ServiceListener,
5656
ServiceStateChange,
57+
ServiceStateChangeHandler,
5758
Signal,
5859
SignalRegistrationInterface,
5960
)
@@ -112,6 +113,7 @@
112113
"ServiceListener",
113114
"ServiceNameAlreadyRegistered",
114115
"ServiceStateChange",
116+
"ServiceStateChangeHandler",
115117
"Zeroconf",
116118
"ZeroconfServiceTypes",
117119
"__version__",

src/zeroconf/_services/__init__.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
import enum
2626
from collections.abc import Callable
27-
from typing import TYPE_CHECKING, Any
27+
from typing import TYPE_CHECKING, Protocol, cast
2828

2929
if TYPE_CHECKING:
3030
from .._core import Zeroconf
@@ -48,15 +48,40 @@ def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
4848
raise NotImplementedError
4949

5050

51+
class ServiceStateChangeHandler(Protocol):
52+
"""Callback contract dispatched by :class:`Signal` to service-state listeners."""
53+
54+
def __call__(
55+
self,
56+
*,
57+
zeroconf: Zeroconf,
58+
service_type: str,
59+
name: str,
60+
state_change: ServiceStateChange,
61+
) -> None: ...
62+
63+
5164
class Signal:
5265
__slots__ = ("_handlers",)
5366

5467
def __init__(self) -> None:
55-
self._handlers: list[Callable[..., None]] = []
56-
57-
def fire(self, **kwargs: Any) -> None:
68+
self._handlers: list[ServiceStateChangeHandler] = []
69+
70+
def fire(
71+
self,
72+
*,
73+
zeroconf: Zeroconf,
74+
service_type: str,
75+
name: str,
76+
state_change: ServiceStateChange,
77+
) -> None:
5878
for h in self._handlers[:]:
59-
h(**kwargs)
79+
h(
80+
zeroconf=zeroconf,
81+
service_type=service_type,
82+
name=name,
83+
state_change=state_change,
84+
)
6085

6186
@property
6287
def registration_interface(self) -> SignalRegistrationInterface:
@@ -66,13 +91,13 @@ def registration_interface(self) -> SignalRegistrationInterface:
6691
class SignalRegistrationInterface:
6792
__slots__ = ("_handlers",)
6893

69-
def __init__(self, handlers: list[Callable[..., None]]) -> None:
94+
def __init__(self, handlers: list[ServiceStateChangeHandler]) -> None:
7095
self._handlers = handlers
7196

7297
def register_handler(self, handler: Callable[..., None]) -> SignalRegistrationInterface:
73-
self._handlers.append(handler)
98+
self._handlers.append(cast("ServiceStateChangeHandler", handler))
7499
return self
75100

76101
def unregister_handler(self, handler: Callable[..., None]) -> SignalRegistrationInterface:
77-
self._handlers.remove(handler)
102+
self._handlers.remove(cast("ServiceStateChangeHandler", handler))
78103
return self

tests/test_services.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,76 @@ def dummy():
261261

262262
with pytest.raises(ValueError):
263263
interface.unregister_handler(dummy)
264+
265+
266+
def test_signal_fire_dispatches_documented_kwargs():
267+
"""Signal.fire forwards the four documented kwargs to handlers."""
268+
signal = r.Signal()
269+
captured: list[dict[str, Any]] = []
270+
271+
def handler(
272+
*,
273+
zeroconf: Any,
274+
service_type: str,
275+
name: str,
276+
state_change: r.ServiceStateChange,
277+
) -> None:
278+
captured.append(
279+
{
280+
"zeroconf": zeroconf,
281+
"service_type": service_type,
282+
"name": name,
283+
"state_change": state_change,
284+
}
285+
)
286+
287+
signal.registration_interface.register_handler(handler)
288+
sentinel = object()
289+
signal.fire(
290+
zeroconf=sentinel, # type: ignore[arg-type]
291+
service_type="_http._tcp.local.",
292+
name="x._http._tcp.local.",
293+
state_change=r.ServiceStateChange.Added,
294+
)
295+
296+
assert captured == [
297+
{
298+
"zeroconf": sentinel,
299+
"service_type": "_http._tcp.local.",
300+
"name": "x._http._tcp.local.",
301+
"state_change": r.ServiceStateChange.Added,
302+
}
303+
]
304+
305+
306+
def test_signal_fire_rejects_unknown_kwarg():
307+
"""Signal.fire rejects keyword args outside the contract."""
308+
signal = r.Signal()
309+
signal.registration_interface.register_handler(lambda **_: None)
310+
311+
with pytest.raises(TypeError):
312+
signal.fire( # type: ignore[call-arg]
313+
zerocnf=None,
314+
service_type="_http._tcp.local.",
315+
name="x._http._tcp.local.",
316+
state_change=r.ServiceStateChange.Added,
317+
)
318+
319+
320+
def test_signal_fire_rejects_positional_args():
321+
"""Signal.fire is keyword-only, so positional args are rejected."""
322+
signal = r.Signal()
323+
signal.registration_interface.register_handler(lambda **_: None)
324+
325+
with pytest.raises(TypeError):
326+
signal.fire( # type: ignore[misc]
327+
None,
328+
"_http._tcp.local.",
329+
"x._http._tcp.local.",
330+
r.ServiceStateChange.Added,
331+
)
332+
333+
334+
def test_service_state_change_handler_protocol_exported():
335+
"""The handler Protocol is part of the public package surface."""
336+
assert hasattr(r, "ServiceStateChangeHandler")

0 commit comments

Comments
 (0)