Skip to content

Commit a41d7b8

Browse files
authored
Provide an asyncio class for service registration (#347)
* Provide an AIO wrapper for service registration - When using zeroconf with async code, service registration can cause the executor to overload when registering multiple services since each one will have to wait a bit between sending the broadcast. An aio subclass is now available as aio.AsyncZeroconf that implements the following - async_register_service - async_unregister_service - async_update_service - async_close I/O is currently run in the executor to provide backwards compat with existing use cases. These functions avoid overloading the executor by waiting in the event loop instead of the executor threads.
1 parent 7816278 commit a41d7b8

5 files changed

Lines changed: 326 additions & 35 deletions

File tree

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@ mypy:
3636
mypy examples/*.py zeroconf/*.py
3737

3838
test:
39-
pytest -v zeroconf/test.py
39+
pytest -v zeroconf/test.py zeroconf/test_asyncio.py
4040

4141
test_coverage:
42-
pytest -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing zeroconf/test.py
42+
pytest -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing zeroconf/test.py zeroconf/test_asyncio.py
4343

4444
autopep8:
4545
autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i setup.py examples zeroconf

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ mypy;implementation_name=="cpython"
1010
# 0.11.0 breaks things https://github.com/PyCQA/pep8-naming/issues/152
1111
pep8-naming!=0.6.0,!=0.11.0
1212
pytest
13+
pytest-asyncio
1314
pytest-cov

zeroconf/__init__.py

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,16 @@ def service_type_name(type_: str, *, strict: bool = True) -> str:
353353
return service_name + trailer
354354

355355

356+
def instance_name_from_service_info(info: "ServiceInfo") -> str:
357+
"""Calculate the instance name from the ServiceInfo."""
358+
# This is kind of funky because of the subtype based tests
359+
# need to make subtypes a first class citizen
360+
service_name = service_type_name(info.name)
361+
if not info.type.endswith(service_name):
362+
raise BadTypeInNameException
363+
return info.name[: -len(service_name) - 1]
364+
365+
356366
# Exceptions
357367

358368

@@ -2505,8 +2515,6 @@ def __init__(
25052515
for s in self._respond_sockets:
25062516
self.engine.add_reader(self.listener, s)
25072517

2508-
self.debug = None # type: Optional[DNSOutgoing]
2509-
25102518
@property
25112519
def done(self) -> bool:
25122520
return self._GLOBAL_DONE
@@ -2580,6 +2588,7 @@ def update_service(self, info: ServiceInfo) -> None:
25802588
self._broadcast_service(info, _REGISTER_TIME, None)
25812589

25822590
def _broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None:
2591+
"""Send a broadcasts to announce a service at intervals."""
25832592
now = current_time_millis()
25842593
next_time = now
25852594
i = 0
@@ -2589,12 +2598,23 @@ def _broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int
25892598
now = current_time_millis()
25902599
continue
25912600

2592-
out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA)
2593-
self._add_broadcast_answer(out, info, ttl)
2594-
self.send(out)
2601+
self.send_service_broadcast(info, ttl)
25952602
i += 1
25962603
next_time += interval
25972604

2605+
def send_service_broadcast(self, info: ServiceInfo, ttl: Optional[int]) -> None:
2606+
"""Send a broadcast to announce a service."""
2607+
out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA)
2608+
self._add_broadcast_answer(out, info, ttl)
2609+
self.send(out)
2610+
2611+
def send_service_query(self, info: ServiceInfo) -> None:
2612+
"""Send a query to lookup a service."""
2613+
out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA)
2614+
out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN))
2615+
out.add_authorative_answer(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name))
2616+
self.send(out)
2617+
25982618
def _add_broadcast_answer(self, out: DNSOutgoing, info: ServiceInfo, override_ttl: Optional[int]) -> None:
25992619
"""Add answers to broadcast a service."""
26002620
other_ttl = info.other_ttl if override_ttl is None else override_ttl
@@ -2653,43 +2673,31 @@ def check_service(
26532673
) -> None:
26542674
"""Checks the network for a unique service name, modifying the
26552675
ServiceInfo passed in if it is not unique."""
2656-
2657-
# This is kind of funky because of the subtype based tests
2658-
# need to make subtypes a first class citizen
2659-
service_name = service_type_name(info.name)
2660-
if not info.type.endswith(service_name):
2661-
raise BadTypeInNameException
2662-
2663-
instance_name = info.name[: -len(service_name) - 1]
2676+
instance_name = instance_name_from_service_info(info)
2677+
if cooperating_responders:
2678+
return
26642679
next_instance_number = 2
2665-
2666-
now = current_time_millis()
2667-
next_time = now
2680+
next_time = now = current_time_millis()
26682681
i = 0
26692682
while i < 3:
2670-
if not cooperating_responders:
2671-
# check for a name conflict
2672-
while self.cache.current_entry_with_name_and_alias(info.type, info.name):
2673-
if not allow_name_change:
2674-
raise NonUniqueNameException
2675-
2676-
# change the name and look for a conflict
2677-
info.name = '%s-%s.%s' % (instance_name, next_instance_number, info.type)
2678-
next_instance_number += 1
2679-
service_type_name(info.name)
2680-
next_time = now
2681-
i = 0
2683+
# check for a name conflict
2684+
while self.cache.current_entry_with_name_and_alias(info.type, info.name):
2685+
if not allow_name_change:
2686+
raise NonUniqueNameException
2687+
2688+
# change the name and look for a conflict
2689+
info.name = '%s-%s.%s' % (instance_name, next_instance_number, info.type)
2690+
next_instance_number += 1
2691+
service_type_name(info.name)
2692+
next_time = now
2693+
i = 0
26822694

26832695
if now < next_time:
26842696
self.wait(next_time - now)
26852697
now = current_time_millis()
26862698
continue
26872699

2688-
out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA)
2689-
self.debug = out
2690-
out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN))
2691-
out.add_authorative_answer(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name))
2692-
self.send(out)
2700+
self.send_service_query(info)
26932701
i += 1
26942702
next_time += _CHECK_TIME
26952703

zeroconf/asyncio.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine
2+
Copyright 2003 Paul Scott-Murphy, 2014 William McBrine
3+
4+
This module provides a framework for the use of DNS Service Discovery
5+
using IP multicast.
6+
7+
This library is free software; you can redistribute it and/or
8+
modify it under the terms of the GNU Lesser General Public
9+
License as published by the Free Software Foundation; either
10+
version 2.1 of the License, or (at your option) any later version.
11+
12+
This library is distributed in the hope that it will be useful,
13+
but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15+
Lesser General Public License for more details.
16+
17+
You should have received a copy of the GNU Lesser General Public
18+
License along with this library; if not, write to the Free Software
19+
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301
20+
USA
21+
"""
22+
import asyncio
23+
from typing import Optional
24+
25+
from . import (
26+
IPVersion,
27+
InterfaceChoice,
28+
InterfacesType,
29+
NonUniqueNameException,
30+
ServiceInfo,
31+
Zeroconf,
32+
_CHECK_TIME,
33+
_REGISTER_TIME,
34+
_UNREGISTER_TIME,
35+
instance_name_from_service_info,
36+
)
37+
38+
39+
class AsyncZeroconf:
40+
"""Implementation of Zeroconf Multicast DNS Service Discovery
41+
42+
Supports registration, unregistration, queries and browsing.
43+
44+
The async version is currently a wrapper around the sync version
45+
with I/O being done in the executor for backwards compatibility.
46+
"""
47+
48+
def __init__(
49+
self,
50+
interfaces: InterfacesType = InterfaceChoice.All,
51+
unicast: bool = False,
52+
ip_version: Optional[IPVersion] = None,
53+
apple_p2p: bool = False,
54+
) -> None:
55+
"""Creates an instance of the Zeroconf class, establishing
56+
multicast communications, listening and reaping threads.
57+
58+
:param interfaces: :class:`InterfaceChoice` or a list of IP addresses
59+
(IPv4 and IPv6) and interface indexes (IPv6 only).
60+
61+
IPv6 notes for non-POSIX systems:
62+
* `InterfaceChoice.All` is an alias for `InterfaceChoice.Default`
63+
on Python versions before 3.8.
64+
65+
Also listening on loopback (``::1``) doesn't work, use a real address.
66+
:param ip_version: IP versions to support. If `choice` is a list, the default is detected
67+
from it. Otherwise defaults to V4 only for backward compatibility.
68+
:param apple_p2p: use AWDL interface (only macOS)
69+
"""
70+
self.zeroconf = Zeroconf(
71+
interfaces=interfaces,
72+
unicast=unicast,
73+
ip_version=ip_version,
74+
apple_p2p=apple_p2p,
75+
)
76+
self.loop = asyncio.get_event_loop()
77+
78+
async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None:
79+
"""Send a broadcasts to announce a service at intervals."""
80+
for i in range(3):
81+
if i != 0:
82+
await asyncio.sleep(interval / 1000)
83+
await self.loop.run_in_executor(None, self.zeroconf.send_service_broadcast, info, ttl)
84+
85+
async def async_register_service(
86+
self,
87+
info: ServiceInfo,
88+
cooperating_responders: bool = False,
89+
) -> None:
90+
"""Registers service information to the network with a default TTL.
91+
Zeroconf will then respond to requests for information for that
92+
service. The name of the service may be changed if needed to make
93+
it unique on the network. Additionally multiple cooperating responders
94+
can register the same service on the network for resilience
95+
(if you want this behavior set `cooperating_responders` to `True`).
96+
97+
The service will be broadcast in a task.
98+
"""
99+
await self.async_check_service(info, cooperating_responders)
100+
await self.loop.run_in_executor(None, self.zeroconf.registry.add, info)
101+
asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None))
102+
103+
async def async_check_service(self, info: ServiceInfo, cooperating_responders: bool = False) -> None:
104+
"""Checks the network for a unique service name."""
105+
instance_name_from_service_info(info)
106+
if cooperating_responders:
107+
return
108+
for i in range(3):
109+
# check for a name conflict
110+
if self.zeroconf.cache.current_entry_with_name_and_alias(info.type, info.name):
111+
raise NonUniqueNameException
112+
if i != 0:
113+
await asyncio.sleep(_CHECK_TIME / 1000)
114+
await self.loop.run_in_executor(None, self.zeroconf.send_service_query, info)
115+
116+
async def async_unregister_service(self, info: ServiceInfo) -> None:
117+
"""Unregister a service.
118+
119+
The service will be broadcast in a task.
120+
"""
121+
await self.loop.run_in_executor(None, self.zeroconf.registry.remove, info)
122+
asyncio.ensure_future(self._async_broadcast_service(info, _UNREGISTER_TIME, 0))
123+
124+
async def async_update_service(self, info: ServiceInfo) -> None:
125+
"""Registers service information to the network with a default TTL.
126+
Zeroconf will then respond to requests for information for that
127+
service.
128+
129+
The service will be broadcast in a task.
130+
"""
131+
await self.loop.run_in_executor(None, self.zeroconf.registry.update, info)
132+
asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None))
133+
134+
async def async_close(self) -> None:
135+
"""Ends the background threads, and prevent this instance from
136+
servicing further queries."""
137+
await self.loop.run_in_executor(None, self.zeroconf.close)

0 commit comments

Comments
 (0)