Skip to content
Draft
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
2 changes: 2 additions & 0 deletions src/zeroconf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from ._services import ( # noqa # import needed for backwards compat
ServiceListener,
ServiceStateChange,
ServiceStateChangeHandler,
Signal,
SignalRegistrationInterface,
)
Expand Down Expand Up @@ -112,6 +113,7 @@
"ServiceListener",
"ServiceNameAlreadyRegistered",
"ServiceStateChange",
"ServiceStateChangeHandler",
"Zeroconf",
"ZeroconfServiceTypes",
"__version__",
Expand Down
41 changes: 33 additions & 8 deletions src/zeroconf/_services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

import enum
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Protocol, cast

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


class ServiceStateChangeHandler(Protocol):
"""Callback contract dispatched by :class:`Signal` to service-state listeners."""

def __call__(
self,
*,
zeroconf: Zeroconf,
service_type: str,
name: str,
state_change: ServiceStateChange,
) -> None: ...


class Signal:
__slots__ = ("_handlers",)

def __init__(self) -> None:
self._handlers: list[Callable[..., None]] = []

def fire(self, **kwargs: Any) -> None:
self._handlers: list[ServiceStateChangeHandler] = []

def fire(
self,
*,
zeroconf: Zeroconf,
service_type: str,
name: str,
state_change: ServiceStateChange,
) -> None:
for h in self._handlers[:]:
h(**kwargs)
h(
zeroconf=zeroconf,
service_type=service_type,
name=name,
state_change=state_change,
)

@property
def registration_interface(self) -> SignalRegistrationInterface:
Expand All @@ -66,13 +91,13 @@ def registration_interface(self) -> SignalRegistrationInterface:
class SignalRegistrationInterface:
__slots__ = ("_handlers",)

def __init__(self, handlers: list[Callable[..., None]]) -> None:
def __init__(self, handlers: list[ServiceStateChangeHandler]) -> None:
self._handlers = handlers

def register_handler(self, handler: Callable[..., None]) -> SignalRegistrationInterface:
self._handlers.append(handler)
self._handlers.append(cast("ServiceStateChangeHandler", handler))
return self

def unregister_handler(self, handler: Callable[..., None]) -> SignalRegistrationInterface:
self._handlers.remove(handler)
self._handlers.remove(cast("ServiceStateChangeHandler", handler))
return self
73 changes: 73 additions & 0 deletions tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,76 @@ def dummy():

with pytest.raises(ValueError):
interface.unregister_handler(dummy)


def test_signal_fire_dispatches_documented_kwargs():
"""Signal.fire forwards the four documented kwargs to handlers."""
signal = r.Signal()
captured: list[dict[str, Any]] = []

def handler(
*,
zeroconf: Any,
service_type: str,
name: str,
state_change: r.ServiceStateChange,
) -> None:
captured.append(
{
"zeroconf": zeroconf,
"service_type": service_type,
"name": name,
"state_change": state_change,
}
)

signal.registration_interface.register_handler(handler)
sentinel = object()
signal.fire(
zeroconf=sentinel, # type: ignore[arg-type]
service_type="_http._tcp.local.",
name="x._http._tcp.local.",
state_change=r.ServiceStateChange.Added,
)

assert captured == [
{
"zeroconf": sentinel,
"service_type": "_http._tcp.local.",
"name": "x._http._tcp.local.",
"state_change": r.ServiceStateChange.Added,
}
]


def test_signal_fire_rejects_unknown_kwarg():
"""Signal.fire rejects keyword args outside the contract."""
signal = r.Signal()
signal.registration_interface.register_handler(lambda **_: None)

with pytest.raises(TypeError):
signal.fire( # type: ignore[call-arg]
zerocnf=None,
service_type="_http._tcp.local.",
name="x._http._tcp.local.",
state_change=r.ServiceStateChange.Added,
)


def test_signal_fire_rejects_positional_args():
"""Signal.fire is keyword-only, so positional args are rejected."""
signal = r.Signal()
signal.registration_interface.register_handler(lambda **_: None)

with pytest.raises(TypeError):
signal.fire( # type: ignore[misc]
None,
"_http._tcp.local.",
"x._http._tcp.local.",
r.ServiceStateChange.Added,
)


def test_service_state_change_handler_protocol_exported():
"""The handler Protocol is part of the public package surface."""
assert hasattr(r, "ServiceStateChangeHandler")
Loading